@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.
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/model/conditions.d.ts +75 -0
- package/dist/model/conditions.js +297 -0
- package/dist/model/data.d.ts +14 -0
- package/dist/model/data.js +73 -0
- package/dist/model/flow.d.ts +53 -0
- package/dist/model/flow.js +93 -0
- package/dist/model/index.d.ts +17 -0
- package/dist/model/index.js +17 -0
- package/dist/model/limits.d.ts +32 -0
- package/dist/model/limits.js +32 -0
- package/dist/model/ops.d.ts +122 -0
- package/dist/model/ops.js +311 -0
- package/dist/model/recipes.d.ts +12 -0
- package/dist/model/recipes.js +209 -0
- package/dist/model/refs.d.ts +30 -0
- package/dist/model/refs.js +48 -0
- package/dist/model/steps.d.ts +276 -0
- package/dist/model/steps.js +206 -0
- package/dist/model/text.d.ts +30 -0
- package/dist/model/text.js +33 -0
- package/dist/model/triggers.d.ts +42 -0
- package/dist/model/triggers.js +68 -0
- package/dist/model/validate.d.ts +19 -0
- package/dist/model/validate.js +207 -0
- package/dist/model/walk.d.ts +40 -0
- package/dist/model/walk.js +68 -0
- package/dist/runtime/clock.d.ts +12 -0
- package/dist/runtime/clock.js +40 -0
- package/dist/runtime/engine.d.ts +15 -0
- package/dist/runtime/engine.js +578 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/keeps.d.ts +8 -0
- package/dist/runtime/keeps.js +33 -0
- package/dist/runtime/outbox.d.ts +8 -0
- package/dist/runtime/outbox.js +27 -0
- package/dist/runtime/seed.d.ts +7 -0
- package/dist/runtime/seed.js +45 -0
- package/dist/runtime/signals.d.ts +4 -0
- package/dist/runtime/signals.js +13 -0
- package/dist/runtime/types.d.ts +127 -0
- package/dist/runtime/types.js +5 -0
- 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
|
-
#
|
|
1
|
+
# @protoyard/flows
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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;
|