@protoyard/flows 0.0.0-stage → 1.1.1
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 +132 -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
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { evaluateCondition, isStepEnabled, parseFlow, resolveRef, textPreview, } from "../model/index.js";
|
|
2
|
+
import { createMockClock } from "./clock.js";
|
|
3
|
+
import { cloneLists, cloneRecords } from "./keeps.js";
|
|
4
|
+
import { maskUrl } from "./outbox.js";
|
|
5
|
+
import { createIdFactory, createRng, shuffleInPlace } from "./seed.js";
|
|
6
|
+
function resolveText(value, scope) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return '';
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
return value;
|
|
11
|
+
return value
|
|
12
|
+
.map((part) => {
|
|
13
|
+
if (typeof part === 'string')
|
|
14
|
+
return part;
|
|
15
|
+
const resolved = resolveRef(part.ref, scope);
|
|
16
|
+
if (resolved === undefined || resolved === null)
|
|
17
|
+
return '';
|
|
18
|
+
if (typeof resolved === 'object')
|
|
19
|
+
return JSON.stringify(resolved);
|
|
20
|
+
return String(resolved);
|
|
21
|
+
})
|
|
22
|
+
.join('');
|
|
23
|
+
}
|
|
24
|
+
function scopeOf(state) {
|
|
25
|
+
return {
|
|
26
|
+
trigger: state.trigger,
|
|
27
|
+
outputs: state.outputs,
|
|
28
|
+
record: state.record,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function resolveFields(fields, scope) {
|
|
32
|
+
const out = {};
|
|
33
|
+
if (!fields)
|
|
34
|
+
return out;
|
|
35
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
36
|
+
out[key] = resolveText(value, scope);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function audienceIds(audience) {
|
|
41
|
+
return audience.map((item) => item.id);
|
|
42
|
+
}
|
|
43
|
+
function channelKind(channel) {
|
|
44
|
+
if (channel === 'email')
|
|
45
|
+
return 'email';
|
|
46
|
+
if (channel === 'inbox')
|
|
47
|
+
return 'inbox';
|
|
48
|
+
if (channel === 'notification')
|
|
49
|
+
return 'notification';
|
|
50
|
+
return 'channel';
|
|
51
|
+
}
|
|
52
|
+
function pushOutbox(state, stepId, item) {
|
|
53
|
+
state.outbox.push({
|
|
54
|
+
id: state.nextId('out'),
|
|
55
|
+
at: state.clock.nowIso(),
|
|
56
|
+
flowId: state.flowId,
|
|
57
|
+
runId: state.runId,
|
|
58
|
+
stepId,
|
|
59
|
+
...item,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function pushEffect(state, type, stepId, payload) {
|
|
63
|
+
state.effects.push({ type, stepId, payload });
|
|
64
|
+
}
|
|
65
|
+
function pushSignal(state, kind, stepId, title, detail) {
|
|
66
|
+
state.signals.push({
|
|
67
|
+
id: state.nextId('sig'),
|
|
68
|
+
kind,
|
|
69
|
+
at: state.clock.nowIso(),
|
|
70
|
+
resolved: false,
|
|
71
|
+
flowId: state.flowId,
|
|
72
|
+
runId: state.runId,
|
|
73
|
+
stepId,
|
|
74
|
+
title,
|
|
75
|
+
detail,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function mockAssistant(task, input, labels, rng) {
|
|
79
|
+
const text = input === undefined || input === null
|
|
80
|
+
? ''
|
|
81
|
+
: typeof input === 'string'
|
|
82
|
+
? input
|
|
83
|
+
: JSON.stringify(input);
|
|
84
|
+
switch (task) {
|
|
85
|
+
case 'summarize':
|
|
86
|
+
return { text: text ? `Summary: ${text.slice(0, 120)}` : 'Summary: (empty)' };
|
|
87
|
+
case 'classify': {
|
|
88
|
+
if (labels && labels.length > 0) {
|
|
89
|
+
const index = Math.floor(rng() * labels.length) % labels.length;
|
|
90
|
+
return { label: labels[index], confidence: 0.5 + rng() * 0.49 };
|
|
91
|
+
}
|
|
92
|
+
return { label: 'unknown', confidence: 0 };
|
|
93
|
+
}
|
|
94
|
+
case 'extract':
|
|
95
|
+
return { fields: typeof input === 'object' && input !== null ? input : { value: text } };
|
|
96
|
+
case 'draft':
|
|
97
|
+
return { text: text ? `Draft: ${text.slice(0, 200)}` : 'Draft: (empty)' };
|
|
98
|
+
case 'check':
|
|
99
|
+
return { ok: true, notes: text ? `Checked: ${text.slice(0, 80)}` : 'Checked' };
|
|
100
|
+
default:
|
|
101
|
+
return { text: textPreview(String(task)) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function answerForAsk(state, stepId, questions) {
|
|
105
|
+
const raw = state.answers[stepId];
|
|
106
|
+
if (raw === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
const first = questions[0]?.id;
|
|
112
|
+
return first ? { [first]: raw } : { value: raw };
|
|
113
|
+
}
|
|
114
|
+
function executeStep(state, step) {
|
|
115
|
+
if (!isStepEnabled(step))
|
|
116
|
+
return 'ok';
|
|
117
|
+
const scope = scopeOf(state);
|
|
118
|
+
if (step.only) {
|
|
119
|
+
const outcome = evaluateCondition(step.only, scope, { judge: state.judge });
|
|
120
|
+
if (outcome.status === 'fail')
|
|
121
|
+
return 'ok';
|
|
122
|
+
if (outcome.status === 'needs_judgment') {
|
|
123
|
+
state.status = 'waiting';
|
|
124
|
+
state.error = 'Condition needs judgment';
|
|
125
|
+
pushSignal(state, 'waiting', step.id, 'Waiting on judgment', outcome.phrases.join('; '));
|
|
126
|
+
return 'waiting';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (step.delayMinutes)
|
|
130
|
+
state.clock.advanceMinutes(step.delayMinutes);
|
|
131
|
+
state.path.push(step.id);
|
|
132
|
+
switch (step.type) {
|
|
133
|
+
case 'wait': {
|
|
134
|
+
state.clock.advanceMinutes(step.minutes);
|
|
135
|
+
state.outputs[step.id] = { waitedMinutes: step.minutes };
|
|
136
|
+
return 'ok';
|
|
137
|
+
}
|
|
138
|
+
case 'wait_until': {
|
|
139
|
+
// Single-shot mock: treat as immediate for time; otherwise wait for people/events.
|
|
140
|
+
if (step.until === 'time') {
|
|
141
|
+
state.outputs[step.id] = { until: 'time', time: step.time ?? null };
|
|
142
|
+
return 'ok';
|
|
143
|
+
}
|
|
144
|
+
state.status = 'waiting';
|
|
145
|
+
pushSignal(state, 'waiting', step.id, 'Waiting', `Waiting until ${step.until}${step.event ? ` (${step.event})` : ''}`);
|
|
146
|
+
state.outputs[step.id] = { until: step.until, waiting: true };
|
|
147
|
+
return 'waiting';
|
|
148
|
+
}
|
|
149
|
+
case 'message': {
|
|
150
|
+
const text = resolveText(step.text, scope);
|
|
151
|
+
const to = audienceIds(step.to);
|
|
152
|
+
pushOutbox(state, step.id, {
|
|
153
|
+
kind: channelKind(step.channel),
|
|
154
|
+
text,
|
|
155
|
+
to,
|
|
156
|
+
channel: step.channel,
|
|
157
|
+
...(step.subject ? { subject: step.subject } : {}),
|
|
158
|
+
});
|
|
159
|
+
pushEffect(state, 'message', step.id, {
|
|
160
|
+
channel: step.channel,
|
|
161
|
+
to,
|
|
162
|
+
text,
|
|
163
|
+
subject: step.subject ?? null,
|
|
164
|
+
});
|
|
165
|
+
state.outputs[step.id] = { sent: true, text };
|
|
166
|
+
return 'ok';
|
|
167
|
+
}
|
|
168
|
+
case 'reply': {
|
|
169
|
+
const text = resolveText(step.text, scope);
|
|
170
|
+
pushOutbox(state, step.id, { kind: 'reply', text });
|
|
171
|
+
pushEffect(state, 'reply', step.id, { text });
|
|
172
|
+
state.outputs[step.id] = { text };
|
|
173
|
+
return 'ok';
|
|
174
|
+
}
|
|
175
|
+
case 'ask': {
|
|
176
|
+
const answers = answerForAsk(state, step.id, step.questions);
|
|
177
|
+
if (!answers) {
|
|
178
|
+
if (state.waitForPeople) {
|
|
179
|
+
state.status = 'waiting';
|
|
180
|
+
pushSignal(state, 'waiting', step.id, 'Waiting for replies', 'Ask step needs answers');
|
|
181
|
+
state.outputs[step.id] = { waiting: true, questions: step.questions.map((q) => q.id) };
|
|
182
|
+
return 'waiting';
|
|
183
|
+
}
|
|
184
|
+
state.status = 'failed';
|
|
185
|
+
state.error = `Missing answers for ask step ${step.id}`;
|
|
186
|
+
pushSignal(state, 'failed', step.id, 'Missing answers', state.error);
|
|
187
|
+
return 'failed';
|
|
188
|
+
}
|
|
189
|
+
pushEffect(state, 'ask', step.id, {
|
|
190
|
+
audience: audienceIds(step.audience),
|
|
191
|
+
questions: step.questions,
|
|
192
|
+
answers,
|
|
193
|
+
});
|
|
194
|
+
state.outputs[step.id] = { answers, replies: answers };
|
|
195
|
+
return 'ok';
|
|
196
|
+
}
|
|
197
|
+
case 'approval': {
|
|
198
|
+
const decision = state.decisions[step.id];
|
|
199
|
+
if (!decision) {
|
|
200
|
+
if (state.waitForPeople) {
|
|
201
|
+
state.status = 'waiting';
|
|
202
|
+
pushSignal(state, 'approval', step.id, 'Needs approval', resolveText(step.prompt, scope) || 'Approval required');
|
|
203
|
+
state.outputs[step.id] = { waiting: true };
|
|
204
|
+
return 'waiting';
|
|
205
|
+
}
|
|
206
|
+
state.status = 'failed';
|
|
207
|
+
state.error = `Missing decision for approval step ${step.id}`;
|
|
208
|
+
pushSignal(state, 'failed', step.id, 'Missing decision', state.error);
|
|
209
|
+
return 'failed';
|
|
210
|
+
}
|
|
211
|
+
const prompt = resolveText(step.prompt, scope);
|
|
212
|
+
pushEffect(state, 'approval', step.id, {
|
|
213
|
+
decision,
|
|
214
|
+
prompt,
|
|
215
|
+
approvers: audienceIds(step.approvers),
|
|
216
|
+
});
|
|
217
|
+
state.outputs[step.id] = { decision, prompt };
|
|
218
|
+
return 'ok';
|
|
219
|
+
}
|
|
220
|
+
case 'branch': {
|
|
221
|
+
const outcome = evaluateCondition(step.when, scope, { judge: state.judge });
|
|
222
|
+
if (outcome.status === 'needs_judgment') {
|
|
223
|
+
state.status = 'waiting';
|
|
224
|
+
state.error = 'Branch needs judgment';
|
|
225
|
+
pushSignal(state, 'waiting', step.id, 'Waiting on judgment', outcome.phrases.join('; '));
|
|
226
|
+
return 'waiting';
|
|
227
|
+
}
|
|
228
|
+
const arm = outcome.status === 'pass' ? step.then : step.otherwise;
|
|
229
|
+
state.outputs[step.id] = {
|
|
230
|
+
result: outcome.status,
|
|
231
|
+
arm: outcome.status === 'pass' ? 'then' : 'otherwise',
|
|
232
|
+
};
|
|
233
|
+
return executeSteps(state, arm);
|
|
234
|
+
}
|
|
235
|
+
case 'assistant': {
|
|
236
|
+
const input = step.input ? resolveRef(step.input, scope) : undefined;
|
|
237
|
+
const result = mockAssistant(step.task, input, step.labels, state.rng);
|
|
238
|
+
pushEffect(state, 'assistant', step.id, {
|
|
239
|
+
task: step.task,
|
|
240
|
+
result,
|
|
241
|
+
instructions: resolveText(step.instructions, scope) || null,
|
|
242
|
+
});
|
|
243
|
+
state.outputs[step.id] = result;
|
|
244
|
+
return 'ok';
|
|
245
|
+
}
|
|
246
|
+
case 'save_record': {
|
|
247
|
+
const title = resolveText(step.title, scope) || step.collection;
|
|
248
|
+
const fields = resolveFields(step.fields, scope);
|
|
249
|
+
const record = {
|
|
250
|
+
id: state.nextId('rec'),
|
|
251
|
+
collection: step.collection,
|
|
252
|
+
title,
|
|
253
|
+
state: step.state ?? null,
|
|
254
|
+
fields,
|
|
255
|
+
runId: state.runId,
|
|
256
|
+
createdAt: state.clock.nowIso(),
|
|
257
|
+
history: step.state ? [{ to: step.state, at: state.clock.nowIso(), by: null }] : [],
|
|
258
|
+
};
|
|
259
|
+
const bucket = state.records[step.collection] ?? [];
|
|
260
|
+
bucket.push(record);
|
|
261
|
+
state.records[step.collection] = bucket;
|
|
262
|
+
state.currentRecordId = record.id;
|
|
263
|
+
pushEffect(state, 'save_record', step.id, {
|
|
264
|
+
collection: step.collection,
|
|
265
|
+
recordId: record.id,
|
|
266
|
+
title,
|
|
267
|
+
state: record.state,
|
|
268
|
+
fields,
|
|
269
|
+
});
|
|
270
|
+
state.outputs[step.id] = { recordId: record.id, title, state: record.state, fields };
|
|
271
|
+
return 'ok';
|
|
272
|
+
}
|
|
273
|
+
case 'get_records': {
|
|
274
|
+
const all = state.records[step.collection] ?? [];
|
|
275
|
+
const sinceMs = state.clock.nowMs() - step.sinceDays * 86_400_000;
|
|
276
|
+
const filtered = all
|
|
277
|
+
.filter((row) => Date.parse(row.createdAt) >= sinceMs)
|
|
278
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
|
279
|
+
.slice(0, step.limit);
|
|
280
|
+
state.outputs[step.id] = { records: filtered, count: filtered.length };
|
|
281
|
+
return 'ok';
|
|
282
|
+
}
|
|
283
|
+
case 'set_state': {
|
|
284
|
+
// Apply to the current record when one was saved in this run; else record-level output only.
|
|
285
|
+
if (state.currentRecordId) {
|
|
286
|
+
for (const rows of Object.values(state.records)) {
|
|
287
|
+
const row = rows.find((r) => r.id === state.currentRecordId);
|
|
288
|
+
if (row) {
|
|
289
|
+
row.state = step.state;
|
|
290
|
+
row.history.push({ to: step.state, at: state.clock.nowIso(), by: null });
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
pushEffect(state, 'set_state', step.id, {
|
|
296
|
+
state: step.state,
|
|
297
|
+
recordId: state.currentRecordId,
|
|
298
|
+
});
|
|
299
|
+
state.outputs[step.id] = { state: step.state, recordId: state.currentRecordId };
|
|
300
|
+
return 'ok';
|
|
301
|
+
}
|
|
302
|
+
case 'pick_from_list': {
|
|
303
|
+
const list = state.lists[step.list] ?? { items: [], used: [], history: [] };
|
|
304
|
+
state.lists[step.list] = list;
|
|
305
|
+
let picked = null;
|
|
306
|
+
if (step.order === 'next_unused') {
|
|
307
|
+
picked = list.items.find((item) => !list.used.includes(item)) ?? null;
|
|
308
|
+
if (picked)
|
|
309
|
+
list.used.push(picked);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
if (list.items.length > 0) {
|
|
313
|
+
const index = Math.floor(state.rng() * list.items.length) % list.items.length;
|
|
314
|
+
picked = list.items[index] ?? null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
state.outputs[step.id] = { item: picked, list: step.list };
|
|
318
|
+
pushEffect(state, 'pick_from_list', step.id, { list: step.list, item: picked });
|
|
319
|
+
return 'ok';
|
|
320
|
+
}
|
|
321
|
+
case 'update_list': {
|
|
322
|
+
const list = state.lists[step.list] ?? { items: [], used: [], history: [] };
|
|
323
|
+
state.lists[step.list] = list;
|
|
324
|
+
const value = resolveText(step.value, scope);
|
|
325
|
+
if (step.action === 'add') {
|
|
326
|
+
if (!list.items.includes(value))
|
|
327
|
+
list.items.push(value);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
list.items = list.items.filter((item) => item !== value);
|
|
331
|
+
list.used = list.used.filter((item) => item !== value);
|
|
332
|
+
}
|
|
333
|
+
pushEffect(state, 'update_list', step.id, { list: step.list, action: step.action, value });
|
|
334
|
+
state.outputs[step.id] = {
|
|
335
|
+
list: step.list,
|
|
336
|
+
action: step.action,
|
|
337
|
+
value,
|
|
338
|
+
items: [...list.items],
|
|
339
|
+
};
|
|
340
|
+
return 'ok';
|
|
341
|
+
}
|
|
342
|
+
case 'group_people': {
|
|
343
|
+
const pool = [...step.from];
|
|
344
|
+
const ids = pool.map((p) => p.id);
|
|
345
|
+
shuffleInPlace(ids, state.rng);
|
|
346
|
+
let groups = [];
|
|
347
|
+
if (step.mode === 'pick_one') {
|
|
348
|
+
const first = ids[0];
|
|
349
|
+
groups = first !== undefined ? [[first]] : [];
|
|
350
|
+
}
|
|
351
|
+
else if (step.mode === 'pairs') {
|
|
352
|
+
for (let i = 0; i < ids.length; i += 2) {
|
|
353
|
+
groups.push(ids.slice(i, i + 2));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const size = step.size ?? 3;
|
|
358
|
+
for (let i = 0; i < ids.length; i += size) {
|
|
359
|
+
groups.push(ids.slice(i, i + size));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
pushEffect(state, 'group_people', step.id, { mode: step.mode, groups });
|
|
363
|
+
state.outputs[step.id] = { groups, mode: step.mode };
|
|
364
|
+
return 'ok';
|
|
365
|
+
}
|
|
366
|
+
case 'call_tool': {
|
|
367
|
+
const input = resolveFields(step.input, scope);
|
|
368
|
+
const handler = state.tools[step.tool];
|
|
369
|
+
if (!handler) {
|
|
370
|
+
state.status = 'failed';
|
|
371
|
+
state.error = `Unknown tool: ${step.tool}`;
|
|
372
|
+
pushSignal(state, 'failed', step.id, 'Tool missing', state.error);
|
|
373
|
+
return 'failed';
|
|
374
|
+
}
|
|
375
|
+
const result = handler(input);
|
|
376
|
+
const maybeUrl = result && typeof result === 'object' && result !== null && 'url' in result
|
|
377
|
+
? String(result.url)
|
|
378
|
+
: undefined;
|
|
379
|
+
// Outbox never stores raw URLs; effects keep the full result for the service adapter.
|
|
380
|
+
const outboxText = typeof result === 'string'
|
|
381
|
+
? result
|
|
382
|
+
: JSON.stringify(maybeUrl && result && typeof result === 'object'
|
|
383
|
+
? { ...result, url: maskUrl(maybeUrl) }
|
|
384
|
+
: (result ?? null));
|
|
385
|
+
pushOutbox(state, step.id, {
|
|
386
|
+
kind: 'tool',
|
|
387
|
+
text: outboxText,
|
|
388
|
+
tool: step.tool,
|
|
389
|
+
action: step.action,
|
|
390
|
+
});
|
|
391
|
+
if (maybeUrl) {
|
|
392
|
+
pushOutbox(state, step.id, {
|
|
393
|
+
kind: 'http',
|
|
394
|
+
text: `Tool ${step.tool} · ${step.action}`,
|
|
395
|
+
url: maskUrl(maybeUrl),
|
|
396
|
+
method: 'TOOL',
|
|
397
|
+
tool: step.tool,
|
|
398
|
+
action: step.action,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
pushEffect(state, 'call_tool', step.id, {
|
|
402
|
+
tool: step.tool,
|
|
403
|
+
action: step.action,
|
|
404
|
+
input,
|
|
405
|
+
result,
|
|
406
|
+
});
|
|
407
|
+
state.outputs[step.id] = { result, tool: step.tool, action: step.action };
|
|
408
|
+
return 'ok';
|
|
409
|
+
}
|
|
410
|
+
case 'start_flow': {
|
|
411
|
+
pushEffect(state, 'start_flow', step.id, { flowId: step.flowId });
|
|
412
|
+
state.outputs[step.id] = { flowId: step.flowId, started: true };
|
|
413
|
+
return 'ok';
|
|
414
|
+
}
|
|
415
|
+
default: {
|
|
416
|
+
state.status = 'failed';
|
|
417
|
+
state.error = `Unsupported step type`;
|
|
418
|
+
return 'failed';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function executeSteps(state, steps) {
|
|
423
|
+
for (const step of steps) {
|
|
424
|
+
if (state.status === 'waiting' || state.status === 'failed' || state.status === 'cancelled') {
|
|
425
|
+
return state.status === 'failed' ? 'failed' : 'waiting';
|
|
426
|
+
}
|
|
427
|
+
const result = executeStep(state, step);
|
|
428
|
+
if (result !== 'ok')
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
return 'ok';
|
|
432
|
+
}
|
|
433
|
+
function seedListsFromFlow(flow, lists) {
|
|
434
|
+
for (const list of flow.lists ?? []) {
|
|
435
|
+
if (!lists[list.id]) {
|
|
436
|
+
lists[list.id] = { items: [...(list.items ?? [])], used: [], history: [] };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Run a flow once against a mock context. Pure with respect to the inputs:
|
|
442
|
+
* the same flow, options and clock produce identical results. Does not mutate
|
|
443
|
+
* the input flow or the caller's keeps maps.
|
|
444
|
+
*/
|
|
445
|
+
export function runFlow(flow, options = {}) {
|
|
446
|
+
const parsed = parseFlow(flow);
|
|
447
|
+
if (!parsed.ok || !parsed.flow) {
|
|
448
|
+
const clock = createMockClock(options.now);
|
|
449
|
+
const nextId = createIdFactory(options.seed);
|
|
450
|
+
const runId = nextId('run');
|
|
451
|
+
return {
|
|
452
|
+
run: {
|
|
453
|
+
id: runId,
|
|
454
|
+
flowId: flow.id,
|
|
455
|
+
status: 'failed',
|
|
456
|
+
startedAt: clock.nowIso(),
|
|
457
|
+
finishedAt: clock.nowIso(),
|
|
458
|
+
trigger: { ...(options.trigger ?? {}) },
|
|
459
|
+
error: 'Flow failed validation',
|
|
460
|
+
path: [],
|
|
461
|
+
},
|
|
462
|
+
outputs: {},
|
|
463
|
+
effects: [],
|
|
464
|
+
outbox: [],
|
|
465
|
+
keeps: { records: [], lists: {} },
|
|
466
|
+
signals: [
|
|
467
|
+
{
|
|
468
|
+
id: nextId('sig'),
|
|
469
|
+
kind: 'failed',
|
|
470
|
+
at: clock.nowIso(),
|
|
471
|
+
resolved: false,
|
|
472
|
+
flowId: flow.id,
|
|
473
|
+
runId,
|
|
474
|
+
title: 'Invalid flow',
|
|
475
|
+
detail: parsed.diagnostics
|
|
476
|
+
.filter((d) => d.severity === 'error')
|
|
477
|
+
.map((d) => d.message)
|
|
478
|
+
.join('; '),
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
now: clock.nowIso(),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const valid = parsed.flow;
|
|
485
|
+
const clock = createMockClock(options.now);
|
|
486
|
+
const nextId = createIdFactory(options.seed);
|
|
487
|
+
const runId = nextId('run');
|
|
488
|
+
const startedAt = clock.nowIso();
|
|
489
|
+
const lists = cloneLists(options.lists);
|
|
490
|
+
seedListsFromFlow(valid, lists);
|
|
491
|
+
const records = cloneRecords(options.records);
|
|
492
|
+
const injectedJudge = options.judge;
|
|
493
|
+
const judge = injectedJudge
|
|
494
|
+
? (request) => {
|
|
495
|
+
const value = injectedJudge({ phrase: request.phrase, judge: request.judge });
|
|
496
|
+
if (value === 'undecided')
|
|
497
|
+
return null;
|
|
498
|
+
return value;
|
|
499
|
+
}
|
|
500
|
+
: undefined;
|
|
501
|
+
const state = {
|
|
502
|
+
clock,
|
|
503
|
+
nextId,
|
|
504
|
+
rng: createRng(options.seed),
|
|
505
|
+
outputs: {},
|
|
506
|
+
effects: [],
|
|
507
|
+
outbox: [],
|
|
508
|
+
signals: [],
|
|
509
|
+
records,
|
|
510
|
+
lists,
|
|
511
|
+
people: options.people ?? [],
|
|
512
|
+
answers: options.answers ?? {},
|
|
513
|
+
decisions: options.decisions ?? {},
|
|
514
|
+
tools: options.tools ?? {},
|
|
515
|
+
judge,
|
|
516
|
+
trigger: options.trigger ?? {},
|
|
517
|
+
record: options.record,
|
|
518
|
+
waitForPeople: options.waitForPeople !== false,
|
|
519
|
+
path: [],
|
|
520
|
+
runId,
|
|
521
|
+
flowId: valid.id,
|
|
522
|
+
status: 'running',
|
|
523
|
+
error: null,
|
|
524
|
+
currentRecordId: null,
|
|
525
|
+
};
|
|
526
|
+
const walk = executeSteps(state, valid.steps);
|
|
527
|
+
if (walk === 'ok' && state.status === 'running')
|
|
528
|
+
state.status = 'done';
|
|
529
|
+
const finishedAt = clock.nowIso();
|
|
530
|
+
const allRecords = Object.values(state.records).flat();
|
|
531
|
+
return {
|
|
532
|
+
run: {
|
|
533
|
+
id: runId,
|
|
534
|
+
flowId: valid.id,
|
|
535
|
+
status: state.status,
|
|
536
|
+
startedAt,
|
|
537
|
+
finishedAt: state.status === 'running' ? null : finishedAt,
|
|
538
|
+
trigger: { ...state.trigger },
|
|
539
|
+
error: state.error,
|
|
540
|
+
path: [...state.path],
|
|
541
|
+
},
|
|
542
|
+
outputs: { ...state.outputs },
|
|
543
|
+
effects: [...state.effects],
|
|
544
|
+
outbox: state.outbox.map((item) => item.url ? { ...item, url: maskUrl(item.url) } : { ...item }),
|
|
545
|
+
keeps: {
|
|
546
|
+
records: allRecords.map((row) => ({
|
|
547
|
+
...row,
|
|
548
|
+
fields: { ...row.fields },
|
|
549
|
+
history: row.history.map((h) => ({ ...h })),
|
|
550
|
+
})),
|
|
551
|
+
lists: cloneLists(state.lists),
|
|
552
|
+
},
|
|
553
|
+
signals: [...state.signals],
|
|
554
|
+
now: clock.nowIso(),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Convenience: create a reusable runner bound to default options (people,
|
|
559
|
+
* tools, seed). Each `run` call still takes a fresh flow and overrides.
|
|
560
|
+
*/
|
|
561
|
+
export function createFlowRuntime(defaults = {}) {
|
|
562
|
+
return {
|
|
563
|
+
run(flow, overrides = {}) {
|
|
564
|
+
return runFlow(flow, {
|
|
565
|
+
...defaults,
|
|
566
|
+
...overrides,
|
|
567
|
+
answers: { ...defaults.answers, ...overrides.answers },
|
|
568
|
+
decisions: { ...defaults.decisions, ...overrides.decisions },
|
|
569
|
+
tools: { ...defaults.tools, ...overrides.tools },
|
|
570
|
+
people: overrides.people ?? defaults.people,
|
|
571
|
+
records: overrides.records ?? defaults.records,
|
|
572
|
+
lists: overrides.lists ?? defaults.lists,
|
|
573
|
+
trigger: overrides.trigger ?? defaults.trigger,
|
|
574
|
+
record: overrides.record ?? defaults.record,
|
|
575
|
+
});
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@protoyard/flows` runtime — deterministic mock execution of validated
|
|
3
|
+
* flows. No Svelte, no network; proposed effects only.
|
|
4
|
+
*/
|
|
5
|
+
export { createMockClock, DEFAULT_RUNTIME_NOW_MS, type MockClock, toEpochMs } from './clock.ts';
|
|
6
|
+
export { createFlowRuntime, runFlow } from './engine.ts';
|
|
7
|
+
export { cloneLists, cloneRecords, recordsOf } from './keeps.ts';
|
|
8
|
+
export { type DeliveryFamily, deliveryFamily, maskUrl } from './outbox.ts';
|
|
9
|
+
export { createIdFactory, createRng, hashSeed, shuffleInPlace } from './seed.ts';
|
|
10
|
+
export { attentionOf, openSignals } from './signals.ts';
|
|
11
|
+
export type { FlowKeepRecord, FlowListState, FlowPerson, FlowRun, FlowRunResult, FlowRunStatus, FlowRuntimeOptions, OutboxItem, OutboxKind, ProposedEffect, Signal, SignalKind, } from './types.ts';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@protoyard/flows` runtime — deterministic mock execution of validated
|
|
3
|
+
* flows. No Svelte, no network; proposed effects only.
|
|
4
|
+
*/
|
|
5
|
+
export { createMockClock, DEFAULT_RUNTIME_NOW_MS, toEpochMs } from "./clock.js";
|
|
6
|
+
export { createFlowRuntime, runFlow } from "./engine.js";
|
|
7
|
+
export { cloneLists, cloneRecords, recordsOf } from "./keeps.js";
|
|
8
|
+
export { deliveryFamily, maskUrl } from "./outbox.js";
|
|
9
|
+
export { createIdFactory, createRng, hashSeed, shuffleInPlace } from "./seed.js";
|
|
10
|
+
export { attentionOf, openSignals } from "./signals.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FlowKeepRecord, FlowListState } from './types.ts';
|
|
2
|
+
/** Clone list state so a run never mutates the caller's maps. */
|
|
3
|
+
export declare function cloneLists(lists: Readonly<Record<string, FlowListState>> | undefined): Record<string, FlowListState>;
|
|
4
|
+
export declare function cloneRecords(records: Readonly<Record<string, readonly FlowKeepRecord[]>> | undefined): Record<string, FlowKeepRecord[]>;
|
|
5
|
+
/** Records newest first, optionally filtered by state. */
|
|
6
|
+
export declare function recordsOf(records: readonly FlowKeepRecord[], { state }?: {
|
|
7
|
+
state?: string | null;
|
|
8
|
+
}): FlowKeepRecord[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Clone list state so a run never mutates the caller's maps. */
|
|
2
|
+
export function cloneLists(lists) {
|
|
3
|
+
const out = {};
|
|
4
|
+
if (!lists)
|
|
5
|
+
return out;
|
|
6
|
+
for (const [id, list] of Object.entries(lists)) {
|
|
7
|
+
out[id] = {
|
|
8
|
+
items: [...list.items],
|
|
9
|
+
used: [...list.used],
|
|
10
|
+
history: list.history.map((row) => [...row]),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
export function cloneRecords(records) {
|
|
16
|
+
const out = {};
|
|
17
|
+
if (!records)
|
|
18
|
+
return out;
|
|
19
|
+
for (const [id, rows] of Object.entries(records)) {
|
|
20
|
+
out[id] = rows.map((row) => ({
|
|
21
|
+
...row,
|
|
22
|
+
fields: { ...row.fields },
|
|
23
|
+
history: row.history.map((h) => ({ ...h })),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Records newest first, optionally filtered by state. */
|
|
29
|
+
export function recordsOf(records, { state = null } = {}) {
|
|
30
|
+
return [...records]
|
|
31
|
+
.filter((row) => (state ? row.state === state : true))
|
|
32
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox helpers. Deliveries never leave the browser; URLs are masked before
|
|
3
|
+
* they are shown so secrets in paths and query strings stay hidden.
|
|
4
|
+
*/
|
|
5
|
+
/** Keep the host, hide deep path segments and every query value. */
|
|
6
|
+
export declare function maskUrl(url?: string): string;
|
|
7
|
+
export type DeliveryFamily = 'channel' | 'dm' | 'email' | 'other';
|
|
8
|
+
export declare function deliveryFamily(kind: string): DeliveryFamily;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox helpers. Deliveries never leave the browser; URLs are masked before
|
|
3
|
+
* they are shown so secrets in paths and query strings stay hidden.
|
|
4
|
+
*/
|
|
5
|
+
/** Keep the host, hide deep path segments and every query value. */
|
|
6
|
+
export function maskUrl(url = '') {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = new URL(url);
|
|
9
|
+
const path = parsed.pathname
|
|
10
|
+
.split('/')
|
|
11
|
+
.map((segment, index) => (index > 1 && segment ? '••••' : segment))
|
|
12
|
+
.join('/');
|
|
13
|
+
return `${parsed.protocol}//${parsed.host}${path}${parsed.search ? '?••••' : ''}`;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return '••••';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function deliveryFamily(kind) {
|
|
20
|
+
if (kind === 'channel' || kind === 'thread' || kind === 'notification')
|
|
21
|
+
return 'channel';
|
|
22
|
+
if (kind === 'dm' || kind === 'reply')
|
|
23
|
+
return 'dm';
|
|
24
|
+
if (kind === 'email' || kind === 'inbox')
|
|
25
|
+
return 'email';
|
|
26
|
+
return 'other';
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Small deterministic helpers (no Math.random, no Date.now). */
|
|
2
|
+
export declare function hashSeed(seed: number | string | undefined): number;
|
|
3
|
+
/** Mulberry32 — same stream for the same seed. */
|
|
4
|
+
export declare function createRng(seed: number | string | undefined): () => number;
|
|
5
|
+
export declare function createIdFactory(seed: number | string | undefined): (prefix: string) => string;
|
|
6
|
+
/** Fisher–Yates with an injected rng. */
|
|
7
|
+
export declare function shuffleInPlace<T>(items: T[], rng: () => number): T[];
|