@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailybot, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # @protoyard/flows
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The framework-agnostic workflow engine for Protoyard
4
+ ([ADR-041](../../docs/DECISIONS.md)). A flow is plain, serializable data: how
5
+ a run starts, the steps it takes and what it keeps. Conditions are a closed,
6
+ data-only rule AST. No flow can hold executable JavaScript, a URL, headers or
7
+ a credential, so a flow can never run code or reach a network on its own.
8
+
9
+ > **Status:** the model (`@protoyard/flows/model`) and the deterministic mock
10
+ > runtime (`@protoyard/flows/runtime`) are available. The Svelte editor and
11
+ > run panels are in [`@protoyard/flows-ui`](../flows-ui/README.md).
12
+
13
+ ## The model
14
+
15
+ ```ts
16
+ import { applyOperations, findRecipe, parseFlow } from '@protoyard/flows';
17
+
18
+ const flow = findRecipe('approval-request')!.build({ id: 'expense-approvals' });
19
+ const parsed = parseFlow(JSON.parse(untrustedJson)); // { ok, flow?, diagnostics }
20
+ const edited = applyOperations(flow, [{ op: 'set_status', status: 'active' }]);
21
+ ```
22
+
23
+ | Part | What it holds |
24
+ | --- | --- |
25
+ | `Flow` | `{ formatVersion: 1, id, name, description?, status, triggers, steps, collections?, lists?, labels? }` |
26
+ | Triggers | `schedule` (days, `HH:MM`, zone, every N weeks), `manual` (label, who, surfaces, command), `event` (name, source, filter condition), `webhook` (event type and a sample payload; never a secret) |
27
+ | Steps | `ask`, `approval`, `group_people`, `wait`, `wait_until`, `branch` (`when` / `then` / `otherwise`), `message`, `reply`, `assistant`, `save_record`, `get_records`, `set_state`, `pick_from_list`, `update_list`, `call_tool` (a tool id the prototype registers with its service adapter), `start_flow`. Every step can carry `label`, `enabled`, an `only` condition and `delayMinutes`. `STEP_KINDS` lists each type's label, category, output and whether it produces an effect. |
28
+ | References | `DataRef`: `{ from: 'trigger' \| 'record', path? }` or `{ from: 'step', stepId, path? }`. A path is a list of plain segments, and only own properties are read. Text is a string or parts mixing strings and `{ ref }`. |
29
+ | Conditions | `compare` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `starts_with`, `ends_with`, `in`, `not_in`), `test` (`exists`, `missing`, `empty`, `not_empty`, `true`, `false`), `all` / `any` / `not`, and `judge` (a phrase decided by an injected `FlowJudge`) |
30
+ | Operations | `update_flow`, `set_status`, `add_trigger`, `replace_trigger`, `remove_trigger`, `add_step`, `update_step`, `remove_step`, `move_step`, `duplicate_step`, `add_question`, `remove_question`, `upsert_collection`, `remove_collection`, `upsert_list`, `remove_list` |
31
+ | Recipes | `daily-check-in`, `approval-request`, `incoming-triage`, `weekly-digest`, `rotating-host` (`FLOW_RECIPES`, `findRecipe`) |
32
+
33
+ ### Evaluating conditions
34
+
35
+ `evaluateCondition(condition, { trigger, outputs, record }, { judge? })` is
36
+ pure. It returns `pass`, `fail` or `needs_judgment` with the undecided
37
+ phrases. The logic is three-valued: `all` fails on any failure and `any`
38
+ passes on any pass, whatever the undecided parts say. Numbers and numeric
39
+ strings compare as numbers, and text compares case-insensitively unless
40
+ `caseSensitive` is set. `describeCondition` reads a condition as plain English.
41
+
42
+ ### Validation
43
+
44
+ `parseFlow(input)` runs three stages:
45
+
46
+ 1. `inspectPlainData` walks the input without recursion. It rejects functions,
47
+ symbols, class instances, accessors, non-finite numbers and
48
+ `__proto__` / `constructor` / `prototype` keys, along with anything over the
49
+ size caps.
50
+ 2. The strict zod schema runs next, and unknown keys fail.
51
+ 3. `validateFlow` reports what a schema cannot express. Each diagnostic has a
52
+ severity, a code, a path such as `steps[1].then[0].text` and a step id. The
53
+ checks:
54
+ - duplicate ids;
55
+ - references to unknown steps, or to steps that have not certainly run yet
56
+ (a step inside a branch arm is not available after the branch);
57
+ - unknown collections, states and lists;
58
+ - choice questions with fewer than two options;
59
+ - `wait_until` with no time or event.
60
+
61
+ Warnings cover empty branches, a flow that starts itself, record reads with
62
+ no collection, and an active flow with nothing enabled.
63
+
64
+ `applyOperations` is atomic. It validates the operations as untrusted input,
65
+ applies them to a copy, and keeps the result only if the result parses. On
66
+ failure it returns the original flow, the diagnostics and the failing
67
+ operation index. Copies get deterministic ids (`step-copy`, `step-copy-2`, …).
68
+
69
+ ### Limits (`FLOW_LIMITS`)
70
+
71
+ | Limit | Value |
72
+ | --- | --- |
73
+ | Steps (nested included) | 200 |
74
+ | Branch depth | 8 |
75
+ | Triggers | 8 |
76
+ | Condition depth / parts | 6 / 64 |
77
+ | Questions / choices | 30 / 20 |
78
+ | Audience entries | 50 |
79
+ | Collections / states / lists / list items | 10 / 20 / 10 / 200 |
80
+ | Path segments | 8 |
81
+ | Operations per batch | 200 |
82
+ | Raw depth / values / string length | 48 / 50,000 / 4,000 |
83
+
84
+ ## Mock runtime
85
+
86
+ ```ts
87
+ import { findRecipe, runFlow } from '@protoyard/flows';
88
+ // or: import { runFlow } from '@protoyard/flows/runtime';
89
+
90
+ const flow = findRecipe('approval-request')!.build({ id: 'expense-approvals' });
91
+ const result = runFlow(flow, {
92
+ now: '2026-03-15T10:00:00.000Z',
93
+ seed: 1,
94
+ trigger: { summary: 'Laptop for Ada Example' },
95
+ decisions: { approve: 'approved' },
96
+ });
97
+ // result.run.status === 'done'
98
+ // result.effects → proposed writes for a service adapter
99
+ // result.outbox → masked delivery log (no network)
100
+ // result.keeps → records + lists the run produced
101
+ // result.signals → waiting / approval / failed attention items
102
+ ```
103
+
104
+ | Piece | Role |
105
+ | --- | --- |
106
+ | `runFlow(flow, options)` | One-shot execution; pure w.r.t. inputs (same flow + options → identical result) |
107
+ | `createFlowRuntime(defaults)` | Binds people, tools, seed; each `.run` still takes overrides |
108
+ | `now` / `seed` | Injected clock and deterministic picks (no `Date.now` / `Math.random`) |
109
+ | `answers` / `decisions` | Mock people replies for `ask` / `approval`; missing values leave `waiting` |
110
+ | `tools` | Local handlers only — never `fetch` |
111
+ | `maskUrl` | Host kept; deep path segments and query values become `••••` |
112
+ | `attentionOf` | Open signals ordered for a runs/attention UI |
113
+
114
+ `wait` advances the mock clock; `wait_until` (except `time`) leaves the run
115
+ waiting. Branch arms use `evaluateCondition`. Assistant steps return scripted
116
+ local results from the task + seed. Nothing is sent to a network.
117
+
118
+ ## Honest limits
119
+
120
+ - Every trigger and effect is a simulation. A schedule fires from the simulated
121
+ clock, and `message`, `assistant` and `call_tool` steps become proposed
122
+ effects for a service adapter. Nothing is sent anywhere.
123
+ - `judge` conditions are never decided by the model itself. Without an
124
+ injected judge they stay `needs_judgment`.
125
+ - The runtime does not persist rows. A prototype service carries `effects` and
126
+ `keeps` through `session.assert` → zod → `environment.run` → `db.transaction`.
127
+
128
+ ## Tests
129
+
130
+ ```bash
131
+ pnpm vitest run packages/flows/tests/model.test.ts
132
+ pnpm vitest run packages/flows/tests/runtime.test.ts
133
+ pnpm --filter @protoyard/flows typecheck
134
+ ```
@@ -0,0 +1,2 @@
1
+ export * from './model/index.ts';
2
+ export * from './runtime/index.ts';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./model/index.js";
2
+ export * from "./runtime/index.js";
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+ import { type DataRef, type RefScope } from './refs.ts';
3
+ export type Scalar = string | number | boolean | null;
4
+ export type Operand = {
5
+ ref: DataRef;
6
+ } | {
7
+ value: Scalar;
8
+ } | {
9
+ values: Scalar[];
10
+ };
11
+ export declare const COMPARE_OPERATORS: readonly ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "not_contains", "starts_with", "ends_with", "in", "not_in"];
12
+ export type CompareOperator = (typeof COMPARE_OPERATORS)[number];
13
+ export declare const VALUE_TESTS: readonly ["exists", "missing", "empty", "not_empty", "true", "false"];
14
+ export type ValueTest = (typeof VALUE_TESTS)[number];
15
+ export type Condition = {
16
+ type: 'compare';
17
+ left: Operand;
18
+ op: CompareOperator;
19
+ right: Operand;
20
+ caseSensitive?: boolean;
21
+ } | {
22
+ type: 'test';
23
+ subject: DataRef;
24
+ test: ValueTest;
25
+ } | {
26
+ type: 'all' | 'any';
27
+ conditions: Condition[];
28
+ } | {
29
+ type: 'not';
30
+ condition: Condition;
31
+ } | {
32
+ type: 'judge';
33
+ phrase: string;
34
+ judge?: string;
35
+ };
36
+ export declare const OperandSchema: z.ZodType<Operand>;
37
+ /** Depth (a leaf is 1) and node count of a condition tree. */
38
+ export declare function conditionStats(condition: Condition): {
39
+ depth: number;
40
+ nodes: number;
41
+ };
42
+ /** A condition within the depth and node caps. */
43
+ export declare const ConditionSchema: z.ZodType<Condition>;
44
+ /** Every data reference a condition reads, in document order. */
45
+ export declare function conditionRefs(condition: Condition): DataRef[];
46
+ export interface JudgeRequest {
47
+ phrase: string;
48
+ judge: string;
49
+ scope: RefScope;
50
+ }
51
+ /**
52
+ * Decides a `judge` node: `true` or `false`, or `null` to leave it undecided.
53
+ * Must be pure and deterministic for a given request (a simulation, never a
54
+ * network call).
55
+ */
56
+ export type FlowJudge = (request: JudgeRequest) => boolean | null;
57
+ export type ConditionOutcome = {
58
+ status: 'pass';
59
+ } | {
60
+ status: 'fail';
61
+ } | {
62
+ status: 'needs_judgment';
63
+ phrases: string[];
64
+ };
65
+ /**
66
+ * Evaluates a condition against a run scope with three-valued logic: `all`
67
+ * fails on any failure, `any` passes on any pass, and an undecided `judge`
68
+ * leaves the result `needs_judgment` (listing its phrases) only when the
69
+ * other parts cannot settle it. Pure: no clock, randomness or I/O.
70
+ */
71
+ export declare function evaluateCondition(condition: Condition, scope?: RefScope, options?: {
72
+ judge?: FlowJudge;
73
+ }): ConditionOutcome;
74
+ /** A plain-English reading of a condition, for editors and run logs. */
75
+ export declare function describeCondition(condition: Condition): string;
@@ -0,0 +1,297 @@
1
+ import { z } from 'zod';
2
+ import { FLOW_ID_PATTERN, FLOW_LIMITS } from "./limits.js";
3
+ import { DataRefSchema, describeRef, resolveRef } from "./refs.js";
4
+ export const COMPARE_OPERATORS = [
5
+ 'eq',
6
+ 'neq',
7
+ 'gt',
8
+ 'gte',
9
+ 'lt',
10
+ 'lte',
11
+ 'contains',
12
+ 'not_contains',
13
+ 'starts_with',
14
+ 'ends_with',
15
+ 'in',
16
+ 'not_in',
17
+ ];
18
+ export const VALUE_TESTS = ['exists', 'missing', 'empty', 'not_empty', 'true', 'false'];
19
+ const ScalarSchema = z.union([z.string().max(500), z.number().finite(), z.boolean(), z.null()]);
20
+ export const OperandSchema = z.union([
21
+ z.strictObject({ ref: DataRefSchema }),
22
+ z.strictObject({ value: ScalarSchema }),
23
+ z.strictObject({ values: z.array(ScalarSchema).min(1).max(50) }),
24
+ ]);
25
+ const ConditionShapeSchema = z.lazy(() => z.discriminatedUnion('type', [
26
+ z.strictObject({
27
+ type: z.literal('compare'),
28
+ left: OperandSchema,
29
+ op: z.enum(COMPARE_OPERATORS),
30
+ right: OperandSchema,
31
+ caseSensitive: z.boolean().optional(),
32
+ }),
33
+ z.strictObject({ type: z.literal('test'), subject: DataRefSchema, test: z.enum(VALUE_TESTS) }),
34
+ z.strictObject({
35
+ type: z.enum(['all', 'any']),
36
+ conditions: z.array(ConditionShapeSchema).min(1).max(16),
37
+ }),
38
+ z.strictObject({ type: z.literal('not'), condition: ConditionShapeSchema }),
39
+ z.strictObject({
40
+ type: z.literal('judge'),
41
+ phrase: z.string().trim().min(1).max(280),
42
+ judge: z.string().regex(FLOW_ID_PATTERN).optional(),
43
+ }),
44
+ ]));
45
+ /** Depth (a leaf is 1) and node count of a condition tree. */
46
+ export function conditionStats(condition) {
47
+ let nodes = 0;
48
+ const visit = (node, depth) => {
49
+ nodes += 1;
50
+ if (node.type === 'all' || node.type === 'any') {
51
+ return Math.max(depth, ...node.conditions.map((child) => visit(child, depth + 1)));
52
+ }
53
+ if (node.type === 'not')
54
+ return visit(node.condition, depth + 1);
55
+ return depth;
56
+ };
57
+ return { depth: visit(condition, 1), nodes };
58
+ }
59
+ /** A condition within the depth and node caps. */
60
+ export const ConditionSchema = ConditionShapeSchema.superRefine((condition, ctx) => {
61
+ const { depth, nodes } = conditionStats(condition);
62
+ if (depth > FLOW_LIMITS.maxConditionDepth) {
63
+ ctx.addIssue({
64
+ code: 'custom',
65
+ message: `Conditions nest at most ${FLOW_LIMITS.maxConditionDepth} levels`,
66
+ });
67
+ }
68
+ if (nodes > FLOW_LIMITS.maxConditionNodes) {
69
+ ctx.addIssue({
70
+ code: 'custom',
71
+ message: `A condition holds at most ${FLOW_LIMITS.maxConditionNodes} parts`,
72
+ });
73
+ }
74
+ });
75
+ /** Every data reference a condition reads, in document order. */
76
+ export function conditionRefs(condition) {
77
+ switch (condition.type) {
78
+ case 'compare':
79
+ return [condition.left, condition.right].flatMap((o) => ('ref' in o ? [o.ref] : []));
80
+ case 'test':
81
+ return [condition.subject];
82
+ case 'all':
83
+ case 'any':
84
+ return condition.conditions.flatMap(conditionRefs);
85
+ case 'not':
86
+ return conditionRefs(condition.condition);
87
+ case 'judge':
88
+ return [];
89
+ }
90
+ }
91
+ const PASS = { status: 'pass' };
92
+ const FAIL = { status: 'fail' };
93
+ function operandValue(operand, scope) {
94
+ if ('ref' in operand)
95
+ return resolveRef(operand.ref, scope);
96
+ if ('value' in operand)
97
+ return operand.value;
98
+ return operand.values;
99
+ }
100
+ const isScalar = (value) => value === null || ['string', 'number', 'boolean'].includes(typeof value);
101
+ function asNumber(value) {
102
+ if (typeof value === 'number')
103
+ return Number.isFinite(value) ? value : null;
104
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
105
+ return Number(value);
106
+ }
107
+ return null;
108
+ }
109
+ function same(a, b, caseSensitive) {
110
+ if (!isScalar(a) || !isScalar(b))
111
+ return false;
112
+ const na = asNumber(a);
113
+ const nb = asNumber(b);
114
+ if (na !== null && nb !== null)
115
+ return na === nb;
116
+ if (typeof a === 'string' && typeof b === 'string' && !caseSensitive) {
117
+ return a.toLowerCase() === b.toLowerCase();
118
+ }
119
+ return a === b;
120
+ }
121
+ const text = (value, caseSensitive) => typeof value === 'string' ? (caseSensitive ? value : value.toLowerCase()) : null;
122
+ function contains(left, right, caseSensitive) {
123
+ if (Array.isArray(left))
124
+ return left.some((item) => same(item, right, caseSensitive));
125
+ const haystack = text(left, caseSensitive);
126
+ const needle = isScalar(right) && right !== null ? text(String(right), caseSensitive) : null;
127
+ return haystack !== null && needle !== null && haystack.includes(needle);
128
+ }
129
+ function compare(op, left, right, caseSensitive) {
130
+ switch (op) {
131
+ case 'eq':
132
+ return same(left, right, caseSensitive);
133
+ case 'neq':
134
+ return !same(left, right, caseSensitive);
135
+ case 'gt':
136
+ case 'gte':
137
+ case 'lt':
138
+ case 'lte': {
139
+ const a = asNumber(left);
140
+ const b = asNumber(right);
141
+ if (a === null || b === null)
142
+ return false;
143
+ if (op === 'gt')
144
+ return a > b;
145
+ if (op === 'gte')
146
+ return a >= b;
147
+ if (op === 'lt')
148
+ return a < b;
149
+ return a <= b;
150
+ }
151
+ case 'contains':
152
+ return contains(left, right, caseSensitive);
153
+ case 'not_contains':
154
+ return !contains(left, right, caseSensitive);
155
+ case 'starts_with':
156
+ case 'ends_with': {
157
+ const a = text(left, caseSensitive);
158
+ const b = isScalar(right) && right !== null ? text(String(right), caseSensitive) : null;
159
+ if (a === null || b === null)
160
+ return false;
161
+ return op === 'starts_with' ? a.startsWith(b) : a.endsWith(b);
162
+ }
163
+ case 'in':
164
+ case 'not_in': {
165
+ const list = Array.isArray(right) ? right : [right];
166
+ const found = list.some((item) => same(left, item, caseSensitive));
167
+ return op === 'in' ? found : !found;
168
+ }
169
+ }
170
+ }
171
+ function isEmpty(value) {
172
+ if (value === undefined || value === null || value === '')
173
+ return true;
174
+ if (Array.isArray(value))
175
+ return value.length === 0;
176
+ if (typeof value === 'object')
177
+ return Object.keys(value).length === 0;
178
+ return false;
179
+ }
180
+ function runTest(test, value) {
181
+ switch (test) {
182
+ case 'exists':
183
+ return value !== undefined && value !== null;
184
+ case 'missing':
185
+ return value === undefined || value === null;
186
+ case 'empty':
187
+ return isEmpty(value);
188
+ case 'not_empty':
189
+ return !isEmpty(value);
190
+ case 'true':
191
+ return value === true;
192
+ case 'false':
193
+ return value === false;
194
+ }
195
+ }
196
+ /**
197
+ * Evaluates a condition against a run scope with three-valued logic: `all`
198
+ * fails on any failure, `any` passes on any pass, and an undecided `judge`
199
+ * leaves the result `needs_judgment` (listing its phrases) only when the
200
+ * other parts cannot settle it. Pure: no clock, randomness or I/O.
201
+ */
202
+ export function evaluateCondition(condition, scope = {}, options = {}) {
203
+ switch (condition.type) {
204
+ case 'compare': {
205
+ const left = operandValue(condition.left, scope);
206
+ const right = operandValue(condition.right, scope);
207
+ return compare(condition.op, left, right, condition.caseSensitive === true) ? PASS : FAIL;
208
+ }
209
+ case 'test':
210
+ return runTest(condition.test, resolveRef(condition.subject, scope)) ? PASS : FAIL;
211
+ case 'not': {
212
+ const inner = evaluateCondition(condition.condition, scope, options);
213
+ if (inner.status === 'needs_judgment')
214
+ return inner;
215
+ return inner.status === 'pass' ? FAIL : PASS;
216
+ }
217
+ case 'all':
218
+ case 'any': {
219
+ const decisive = condition.type === 'all' ? 'fail' : 'pass';
220
+ const phrases = [];
221
+ for (const child of condition.conditions) {
222
+ const outcome = evaluateCondition(child, scope, options);
223
+ if (outcome.status === decisive)
224
+ return outcome;
225
+ if (outcome.status === 'needs_judgment')
226
+ phrases.push(...outcome.phrases);
227
+ }
228
+ if (phrases.length > 0)
229
+ return { status: 'needs_judgment', phrases };
230
+ return condition.type === 'all' ? PASS : FAIL;
231
+ }
232
+ case 'judge': {
233
+ const decided = options.judge?.({
234
+ phrase: condition.phrase,
235
+ judge: condition.judge ?? 'assistant',
236
+ scope,
237
+ });
238
+ if (decided === true)
239
+ return PASS;
240
+ if (decided === false)
241
+ return FAIL;
242
+ return { status: 'needs_judgment', phrases: [condition.phrase] };
243
+ }
244
+ }
245
+ }
246
+ /* ---------- reading ---------- */
247
+ const OPERATOR_LABELS = {
248
+ eq: 'is',
249
+ neq: 'is not',
250
+ gt: 'is more than',
251
+ gte: 'is at least',
252
+ lt: 'is less than',
253
+ lte: 'is at most',
254
+ contains: 'contains',
255
+ not_contains: 'does not contain',
256
+ starts_with: 'starts with',
257
+ ends_with: 'ends with',
258
+ in: 'is one of',
259
+ not_in: 'is not one of',
260
+ };
261
+ const TEST_LABELS = {
262
+ exists: 'is set',
263
+ missing: 'is not set',
264
+ empty: 'is empty',
265
+ not_empty: 'has something in it',
266
+ true: 'is yes',
267
+ false: 'is no',
268
+ };
269
+ function describeOperand(operand) {
270
+ if ('ref' in operand)
271
+ return describeRef(operand.ref);
272
+ if ('value' in operand)
273
+ return JSON.stringify(operand.value);
274
+ return operand.values.map((v) => JSON.stringify(v)).join(', ');
275
+ }
276
+ /** A plain-English reading of a condition, for editors and run logs. */
277
+ export function describeCondition(condition) {
278
+ switch (condition.type) {
279
+ case 'compare':
280
+ return `${describeOperand(condition.left)} ${OPERATOR_LABELS[condition.op]} ${describeOperand(condition.right)}`;
281
+ case 'test':
282
+ return `${describeRef(condition.subject)} ${TEST_LABELS[condition.test]}`;
283
+ case 'all':
284
+ case 'any': {
285
+ const joiner = condition.type === 'all' ? ' and ' : ' or ';
286
+ const parts = condition.conditions.map((child) => {
287
+ const inner = describeCondition(child);
288
+ return child.type === 'all' || child.type === 'any' ? `(${inner})` : inner;
289
+ });
290
+ return parts.join(joiner);
291
+ }
292
+ case 'not':
293
+ return `not (${describeCondition(condition.condition)})`;
294
+ case 'judge':
295
+ return condition.phrase;
296
+ }
297
+ }
@@ -0,0 +1,14 @@
1
+ /** Keys that could reach an object prototype if a consumer ever merged them. */
2
+ export declare const FORBIDDEN_KEYS: ReadonlySet<string>;
3
+ export interface DataProblem {
4
+ path: string;
5
+ message: string;
6
+ }
7
+ /**
8
+ * Walks untrusted input without recursion and reports the first problem: a
9
+ * value JSON cannot hold (function, symbol, bigint, class instance, non-finite
10
+ * number), a forbidden key, a string over the cap, or a structure too deep or
11
+ * too large. A flow that passes holds plain data only, so nothing in it can
12
+ * ever be called.
13
+ */
14
+ export declare function inspectPlainData(input: unknown): DataProblem | null;
@@ -0,0 +1,73 @@
1
+ import { FLOW_LIMITS } from "./limits.js";
2
+ /** Keys that could reach an object prototype if a consumer ever merged them. */
3
+ export const FORBIDDEN_KEYS = new Set([
4
+ '__proto__',
5
+ 'constructor',
6
+ 'prototype',
7
+ ]);
8
+ const pointer = (path) => path.length === 0
9
+ ? '$'
10
+ : `$${path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('')}`;
11
+ const isPlainObject = (value) => {
12
+ const proto = Object.getPrototypeOf(value);
13
+ return proto === Object.prototype || proto === null;
14
+ };
15
+ /**
16
+ * Walks untrusted input without recursion and reports the first problem: a
17
+ * value JSON cannot hold (function, symbol, bigint, class instance, non-finite
18
+ * number), a forbidden key, a string over the cap, or a structure too deep or
19
+ * too large. A flow that passes holds plain data only, so nothing in it can
20
+ * ever be called.
21
+ */
22
+ export function inspectPlainData(input) {
23
+ const stack = [
24
+ { value: input, path: [], depth: 0 },
25
+ ];
26
+ let nodes = 0;
27
+ while (stack.length > 0) {
28
+ const { value, path, depth } = stack.pop();
29
+ nodes += 1;
30
+ if (nodes > FLOW_LIMITS.maxDataNodes) {
31
+ return { path: pointer(path), message: `More than ${FLOW_LIMITS.maxDataNodes} values` };
32
+ }
33
+ if (depth > FLOW_LIMITS.maxDataDepth) {
34
+ return { path: pointer(path), message: `Nested deeper than ${FLOW_LIMITS.maxDataDepth}` };
35
+ }
36
+ if (value === null || typeof value === 'boolean')
37
+ continue;
38
+ if (typeof value === 'string') {
39
+ if (value.length > FLOW_LIMITS.maxStringLength) {
40
+ return { path: pointer(path), message: `Text longer than ${FLOW_LIMITS.maxStringLength}` };
41
+ }
42
+ continue;
43
+ }
44
+ if (typeof value === 'number') {
45
+ if (!Number.isFinite(value))
46
+ return { path: pointer(path), message: 'Not a finite number' };
47
+ continue;
48
+ }
49
+ if (typeof value !== 'object') {
50
+ return { path: pointer(path), message: `A ${typeof value} is not plain data` };
51
+ }
52
+ if (Array.isArray(value)) {
53
+ for (const [i, item] of value.entries()) {
54
+ stack.push({ value: item, path: [...path, i], depth: depth + 1 });
55
+ }
56
+ continue;
57
+ }
58
+ if (!isPlainObject(value))
59
+ return { path: pointer(path), message: 'Not a plain object' };
60
+ for (const key of Reflect.ownKeys(value)) {
61
+ if (typeof key === 'symbol')
62
+ return { path: pointer(path), message: 'Symbol keys are not data' };
63
+ if (FORBIDDEN_KEYS.has(key))
64
+ return { path: pointer([...path, key]), message: `Key "${key}" is not allowed` };
65
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
66
+ if (descriptor && !('value' in descriptor)) {
67
+ return { path: pointer([...path, key]), message: 'Accessors are not data' };
68
+ }
69
+ stack.push({ value: descriptor?.value, path: [...path, key], depth: depth + 1 });
70
+ }
71
+ }
72
+ return null;
73
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { type Step } from './steps.ts';
3
+ import { type Trigger } from './triggers.ts';
4
+ import { type FlowDiagnostic } from './validate.ts';
5
+ export declare const FLOW_FORMAT_VERSION = 1;
6
+ export declare const FLOW_STATUSES: readonly ["draft", "active", "paused"];
7
+ export type FlowStatus = (typeof FLOW_STATUSES)[number];
8
+ export declare const CollectionSchema: z.ZodObject<{
9
+ id: z.ZodString;
10
+ noun: z.ZodString;
11
+ plural: z.ZodOptional<z.ZodString>;
12
+ states: z.ZodArray<z.ZodObject<{
13
+ id: z.ZodString;
14
+ label: z.ZodString;
15
+ final: z.ZodOptional<z.ZodBoolean>;
16
+ }, z.core.$strict>>;
17
+ }, z.core.$strict>;
18
+ export type FlowCollection = z.infer<typeof CollectionSchema>;
19
+ export declare const ListSchema: z.ZodObject<{
20
+ id: z.ZodString;
21
+ name: z.ZodString;
22
+ items: z.ZodArray<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ export type FlowList = z.infer<typeof ListSchema>;
25
+ /** A serializable workflow: how it starts, what it does, and what it keeps. */
26
+ export interface Flow {
27
+ formatVersion: 1;
28
+ id: string;
29
+ name: string;
30
+ description?: string;
31
+ status: FlowStatus;
32
+ triggers: Trigger[];
33
+ steps: Step[];
34
+ collections?: FlowCollection[];
35
+ lists?: FlowList[];
36
+ labels?: string[];
37
+ }
38
+ export declare const FlowSchema: z.ZodType<Flow>;
39
+ export type ParseFlowResult = {
40
+ ok: true;
41
+ flow: Flow;
42
+ diagnostics: FlowDiagnostic[];
43
+ } | {
44
+ ok: false;
45
+ diagnostics: FlowDiagnostic[];
46
+ };
47
+ /**
48
+ * Parses untrusted input into a flow: plain-data and size checks first (no
49
+ * recursion over hostile structures), then the strict schema, then the
50
+ * semantic checks of `validateFlow`. Errors fail the parse; warnings ride
51
+ * along with a successful one.
52
+ */
53
+ export declare function parseFlow(input: unknown): ParseFlowResult;