wizards 0.2.24 → 0.2.26

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 (69) hide show
  1. package/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/authoring/guide.js +55 -6
  3. package/apps/runtime/dist/connectors/builtin.js +17 -1
  4. package/apps/runtime/dist/connectors/imap.js +46 -2
  5. package/apps/runtime/dist/connectors/index.js +52 -18
  6. package/apps/runtime/dist/connectors/via-account.js +63 -0
  7. package/apps/runtime/dist/credits/estimate.js +12 -2
  8. package/apps/runtime/dist/engine/decide.js +82 -0
  9. package/apps/runtime/dist/engine/input.js +12 -5
  10. package/apps/runtime/dist/engine/requirements.js +80 -15
  11. package/apps/runtime/dist/engine/runner.js +252 -6
  12. package/apps/runtime/dist/engine/steps.js +206 -18
  13. package/apps/runtime/dist/engine/surface.js +90 -0
  14. package/apps/runtime/dist/render/downloads.js +7 -0
  15. package/apps/runtime/dist/routes/runs.js +18 -5
  16. package/apps/runtime/dist/services/relevance.js +17 -49
  17. package/apps/runtime/dist/tools/pages.js +31 -6
  18. package/apps/web/dist/assets/{Account-thPW7H4A.js → Account-Cnksw5Ea.js} +1 -1
  19. package/apps/web/dist/assets/{ChangelogDialog-mCszGL5_.js → ChangelogDialog-CdtQWq0m.js} +1 -1
  20. package/apps/web/dist/assets/{CreditsDialog-Com2Y3g8.js → CreditsDialog-Ti43boCN.js} +1 -1
  21. package/apps/web/dist/assets/EditorPage-N7EzlRSj.js +3 -0
  22. package/apps/web/dist/assets/{FlowPreview-CRsxtwhV.js → FlowPreview-IZcNrBJi.js} +1 -1
  23. package/apps/web/dist/assets/{Harness-CfgVRYpr.js → Harness-Cj-k02TA.js} +1 -1
  24. package/apps/web/dist/assets/{HomePage-DYKF3C1U.js → HomePage-bjdKkt84.js} +1 -1
  25. package/apps/web/dist/assets/{ImportWizard-BTp7XTsF.js → ImportWizard-DagFXYPM.js} +1 -1
  26. package/apps/web/dist/assets/{LocalRuntime-CYhPXwAv.js → LocalRuntime-Boi_zqQJ.js} +1 -1
  27. package/apps/web/dist/assets/NewWizardPage-DO5J8OpZ.js +2 -0
  28. package/apps/web/dist/assets/ProjectPage-DdG9wYsd.js +2 -0
  29. package/apps/web/dist/assets/{PublicRunner-ClB2-2EF.js → PublicRunner-B4ts9rT6.js} +1 -1
  30. package/apps/web/dist/assets/RunDrawer-LnVfCiYO.js +4 -0
  31. package/apps/web/dist/assets/RunnerView-DwffK7AA.js +70 -0
  32. package/apps/web/dist/assets/{SettingsPage-qDqiZ7th.js → SettingsPage-Cyx_jTlp.js} +1 -1
  33. package/apps/web/dist/assets/{SetupPage-BeBReYLP.js → SetupPage-CBDD4rpC.js} +1 -1
  34. package/apps/web/dist/assets/{SharePage-Cv8toByB.js → SharePage-70niRKaO.js} +1 -1
  35. package/apps/web/dist/assets/{api-rJBQEu1G.js → api-C4M6cbpW.js} +1 -1
  36. package/apps/web/dist/assets/changelog-COyL1B5X.js +1 -0
  37. package/apps/web/dist/assets/definition-DoCLtZZL.js +55 -0
  38. package/apps/web/dist/assets/{esm-DFNBMDKB.js → esm-CfW8Rmtq.js} +1 -1
  39. package/apps/web/dist/assets/index-CJ0YJ5HC.css +1 -0
  40. package/apps/web/dist/assets/{index-CoTWrikR.js → index-DMKXnSf-.js} +3 -3
  41. package/apps/web/dist/assets/meta-6jZygxJq.js +1 -0
  42. package/apps/web/dist/assets/{settings-menu-bsLDpw56.js → settings-menu-Dph4yXbO.js} +1 -1
  43. package/apps/web/dist/assets/{table-2-BdDuBcF2.js → star-UHmZXpjA.js} +1 -1
  44. package/apps/web/dist/assets/ui-BS348L4M.js +1 -0
  45. package/apps/web/dist/assets/wand-sparkles-IEd4jkyn.js +1 -0
  46. package/apps/web/dist/index.html +4 -4
  47. package/apps/web/dist/mcp/flow.html +60 -60
  48. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  49. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  50. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1048 -50
  51. package/node_modules/@engenty-wizards/shared/dist/definition.js +516 -43
  52. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  53. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +1 -1
  54. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  55. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  56. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  57. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  58. package/package.json +1 -1
  59. package/apps/web/dist/assets/EditorPage-Dwg-K4UI.js +0 -2
  60. package/apps/web/dist/assets/NewWizardPage-BgnrGN8A.js +0 -2
  61. package/apps/web/dist/assets/ProjectPage-Cfco8MIG.js +0 -5
  62. package/apps/web/dist/assets/RunDrawer-B50sCUSw.js +0 -1
  63. package/apps/web/dist/assets/RunnerView-CFCmOmJI.js +0 -70
  64. package/apps/web/dist/assets/changelog-BmS3l433.js +0 -1
  65. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  66. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  67. package/apps/web/dist/assets/meta-DJta2rA3.js +0 -1
  68. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  69. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -1,12 +1,20 @@
1
- import { isDecisionStep, MODEL_CLASSES, ruleMatches, } from "@engenty-wizards/shared/definition";
1
+ import { conditionMatches, conditionsOf, isDecidedField, isDecisionStep, isOtherwise, MODEL_CLASSES, ruleMatches, } from "@engenty-wizards/shared/definition";
2
2
  import { classProblem, filmClient } from "../models.js";
3
3
  /**
4
4
  * What a wizard needs of the models before a run starts: each class its steps call, and whether
5
5
  * this runtime can serve it. A step every run passes through blocks the start when its class is
6
6
  * missing; a step behind a branch only warns, since the person may choose around it.
7
7
  */
8
- /** The classes a step calls, as ../engine/steps.ts and ../media/generate.ts call them. */
8
+ /**
9
+ * The classes a step calls, as ../engine/steps.ts and ../media/generate.ts call them — and the
10
+ * classifier where a decision picks its next step or a page's fields.
11
+ */
9
12
  export function stepClasses(step) {
13
+ const own = ownClasses(step);
14
+ const decides = step.next?.some((r) => r.ask) || (step.type === "page" && step.fields.some(isDecidedField));
15
+ return decides && !own.includes("classifier") ? [...own, "classifier"] : own;
16
+ }
17
+ function ownClasses(step) {
10
18
  switch (step.type) {
11
19
  case "agent": {
12
20
  if (isDecisionStep(step)) {
@@ -19,6 +27,9 @@ export function stepClasses(step) {
19
27
  if (step.output.format === "json") {
20
28
  classes.push("classifier");
21
29
  }
30
+ if (step.output.surface && !classes.includes("standard")) {
31
+ classes.push("standard");
32
+ }
22
33
  return classes;
23
34
  }
24
35
  case "generate":
@@ -64,8 +75,11 @@ export function unavoidableFrom(def, from, known) {
64
75
  const successors = (i) => {
65
76
  const out = [];
66
77
  for (const rule of def.steps[i].next ?? []) {
78
+ if (isOtherwise(rule)) {
79
+ continue;
80
+ }
67
81
  const target = rule.goto === "end" ? def.steps.length : (index.get(rule.goto) ?? -1);
68
- if (!(rule.when.field in known)) {
82
+ if (rule.ask || !conditionsOf(rule.when).every((c) => c.field in known)) {
69
83
  if (target >= 0) {
70
84
  out.push(target);
71
85
  }
@@ -76,8 +90,8 @@ export function unavoidableFrom(def, from, known) {
76
90
  return target >= 0 ? [...out, target] : out;
77
91
  }
78
92
  }
79
- // No rule matching goes on to the next step (or ends after the last).
80
- out.push(i + 1);
93
+ // No rule matching goes on to the "otherwise" step, else the next (or ends after the last).
94
+ out.push(otherwiseIndex(def, i, index));
81
95
  return out;
82
96
  };
83
97
  const reachesEnd = (without) => {
@@ -102,11 +116,36 @@ export function unavoidableFrom(def, from, known) {
102
116
  };
103
117
  return new Set(def.steps.filter((_, i) => !reachesEnd(i)).map((s) => s.id));
104
118
  }
119
+ /** Where step `i` goes when none of its rules holds, as an index; the length = the end. */
120
+ function otherwiseIndex(def, i, index) {
121
+ const goto = def.steps[i].next?.find(isOtherwise)?.goto;
122
+ if (goto === undefined) {
123
+ return i + 1;
124
+ }
125
+ return goto === "end" ? def.steps.length : (index.get(goto) ?? i + 1);
126
+ }
105
127
  /** Whether a rule matches given what is known: true, false, or null = it may go either way. */
106
128
  function ruleHolds(rule, facts) {
107
- const k = facts[rule.when.field];
108
- const value = rule.when.value;
109
- switch (rule.when.op) {
129
+ // A decision may go either way.
130
+ if (rule.ask) {
131
+ return null;
132
+ }
133
+ let all = true;
134
+ for (const c of conditionsOf(rule.when)) {
135
+ const holds = conditionHolds(c, facts);
136
+ if (holds === false) {
137
+ return false;
138
+ }
139
+ if (holds === null) {
140
+ all = null;
141
+ }
142
+ }
143
+ return all;
144
+ }
145
+ function conditionHolds(c, facts) {
146
+ const k = facts[c.field];
147
+ const value = c.value;
148
+ switch (c.op) {
110
149
  case "empty":
111
150
  return k?.filled === undefined ? (k?.is !== undefined ? false : null) : !k.filled;
112
151
  case "notEmpty":
@@ -130,15 +169,35 @@ function ruleHolds(rule, facts) {
130
169
  }
131
170
  return list.every((v) => k?.not.includes(v)) ? false : null;
132
171
  }
172
+ case "gt":
173
+ case "lt":
174
+ case "contains":
175
+ if (k?.is !== undefined) {
176
+ return conditionMatches(c, { [c.field]: k.is });
177
+ }
178
+ return k?.filled === false ? false : null;
133
179
  }
134
180
  }
135
- /** What is known once a rule did (`held`) or did not match. */
181
+ /**
182
+ * What is known once a rule did (`held`) or did not match. A rule of several conditions that
183
+ * did not match says nothing about any one of them.
184
+ */
136
185
  function learn(rule, held, facts) {
137
- const field = rule.when.field;
186
+ if (rule.ask) {
187
+ return facts;
188
+ }
189
+ const conditions = conditionsOf(rule.when);
190
+ if (held) {
191
+ return conditions.reduce((known, c) => learnCondition(c, true, known), facts);
192
+ }
193
+ return conditions.length === 1 ? learnCondition(conditions[0], false, facts) : facts;
194
+ }
195
+ function learnCondition(c, held, facts) {
196
+ const field = c.field;
138
197
  const k = { ...(facts[field] ?? { not: [] }) };
139
198
  k.not = [...k.not];
140
- const value = String(rule.when.value);
141
- const op = rule.when.op;
199
+ const value = String(c.value);
200
+ const op = c.op;
142
201
  if ((op === "equals" && held) || (op === "notEquals" && !held)) {
143
202
  k.is = value;
144
203
  k.filled = true;
@@ -146,12 +205,15 @@ function learn(rule, held, facts) {
146
205
  else if ((op === "equals" && !held) || (op === "notEquals" && held)) {
147
206
  k.not.push(value);
148
207
  }
149
- else if (op === "in" && !held && Array.isArray(rule.when.value)) {
150
- k.not.push(...rule.when.value.map(String));
208
+ else if (op === "in" && !held && Array.isArray(c.value)) {
209
+ k.not.push(...c.value.map(String));
151
210
  }
152
211
  else if (op === "empty" || op === "notEmpty") {
153
212
  k.filled = (op === "notEmpty") === held;
154
213
  }
214
+ else if ((op === "gt" || op === "lt" || op === "contains") && held) {
215
+ k.filled = true;
216
+ }
155
217
  return { ...facts, [field]: k };
156
218
  }
157
219
  /**
@@ -185,6 +247,9 @@ export function mostAlongOnePath(def, cost, closed = new Set()) {
185
247
  let known = facts;
186
248
  let decided = false;
187
249
  for (const rule of step.next ?? []) {
250
+ if (isOtherwise(rule)) {
251
+ continue;
252
+ }
188
253
  const holds = ruleHolds(rule, known);
189
254
  if (holds === false) {
190
255
  continue;
@@ -197,7 +262,7 @@ export function mostAlongOnePath(def, cost, closed = new Set()) {
197
262
  known = learn(rule, false, known);
198
263
  }
199
264
  if (!decided) {
200
- consider(i + 1, known);
265
+ consider(otherwiseIndex(def, i, index), known);
201
266
  }
202
267
  return most === null ? null : most + cost(step);
203
268
  };
@@ -1,4 +1,5 @@
1
- import { branchValues, dataRef, formatsFor, LIST_FORMATS, listRef, nextStepId, } from "@engenty-wizards/shared/definition";
1
+ import { marginOf, } from "@engenty-wizards/shared/decision";
2
+ import { branchValues, conditionsOf, dataRef, followRules, formatsFor, isDecidedField, LIST_FORMATS, listRef, loopKey, MAX_SHOWN_FIELDS, optionsFromData, otherwiseOf, pageNeeds, whenHolds, } from "@engenty-wizards/shared/definition";
2
3
  import { eq } from "drizzle-orm";
3
4
  import { nanoid } from "nanoid";
4
5
  import { connectionViews } from "../connectors/index.js";
@@ -17,13 +18,14 @@ import { listRows, scopeOf } from "../store/index.js";
17
18
  import { activeRuns, indexRun, markRunActive } from "../tenants/control.js";
18
19
  import { currentTenant } from "../tenants/tenant.js";
19
20
  import { askPerson, clearStaleAsk, unattended } from "./asks.js";
21
+ import { decide } from "./decide.js";
20
22
  import { emitEvent, recentEvents, signalChanged } from "./events.js";
21
23
  import { readPageInput } from "./input.js";
22
24
  import { pushRun } from "./push.js";
23
25
  import { blockingMessage, closedChoices, missingModels, openValue, stepsWithoutModel, } from "./requirements.js";
24
26
  import { releaseResources, resourcesFor } from "./resources.js";
25
27
  import { runAutomaticStep } from "./steps.js";
26
- import { resolveRef } from "./template.js";
28
+ import { answersAsText, resolveRef } from "./template.js";
27
29
  import { StepError } from "./types.js";
28
30
  /** A command the run cannot take in its current state. */
29
31
  export class RunConflict extends Error {
@@ -226,6 +228,45 @@ export function kick(runId) {
226
228
  signalChanged(runId);
227
229
  });
228
230
  }
231
+ /** What conditions read in this run: answers, outputs, and how many rows each stored list has. */
232
+ async function flowValues(run, state) {
233
+ const counts = {};
234
+ if (run.definition.lists?.length) {
235
+ for (const [id, list] of Object.entries(await storedLists(run))) {
236
+ counts[id] = list.rows.length;
237
+ }
238
+ }
239
+ return branchValues(state.values, state.outputs, counts);
240
+ }
241
+ /**
242
+ * What a page knows besides the person's input: what its fields' `when` read from before the
243
+ * page (its own earlier answers left out, so a revisit decides on what is typed now) and the
244
+ * choices its `optionsFrom` fields find in the run.
245
+ */
246
+ async function pageContext(run, step) {
247
+ const needs = pageNeeds(step.fields);
248
+ const sourced = step.fields.filter((f) => f.optionsFrom);
249
+ const decided = step.fields.some(isDecidedField) ? (run.state.pages?.[step.id]?.shown ?? []) : [];
250
+ if (!needs.length && !sourced.length) {
251
+ return { known: {}, options: {}, decided };
252
+ }
253
+ const all = await flowValues(run, run.state);
254
+ const known = Object.fromEntries(needs.map((ref) => [ref, all[ref]]));
255
+ const lists = sourced.some((f) => f.optionsFrom?.startsWith("lists."))
256
+ ? await storedLists(run)
257
+ : {};
258
+ const options = {};
259
+ for (const field of sourced) {
260
+ const found = optionsFromData(field.optionsFrom ?? "", run.state.outputs, lists);
261
+ if (found.length) {
262
+ options[field.id] = found;
263
+ }
264
+ }
265
+ return { known, options, decided };
266
+ }
267
+ function viewContext(ctx) {
268
+ return { known: ctx.known, options: ctx.options, decidedFields: ctx.decided };
269
+ }
229
270
  /** The wizard's lists with the person's rows, by list id. */
230
271
  async function storedLists(run) {
231
272
  const scope = scopeOf(run);
@@ -243,6 +284,7 @@ async function makeContext(run, project, state, signal) {
243
284
  return {
244
285
  runId: run.id,
245
286
  tenantId: run.tenantId,
287
+ test: run.mode === "test",
246
288
  stepId,
247
289
  store,
248
290
  def: run.definition,
@@ -271,6 +313,146 @@ async function makeContext(run, project, state, signal) {
271
313
  }),
272
314
  };
273
315
  }
316
+ /** A decided branch is taken only when its pick leads the runner-up by this much. */
317
+ const BRANCH_MARGIN = 0.1;
318
+ /**
319
+ * Where the run goes after `stepId`: a `when` rule or the next step at once — counted, so a loop
320
+ * stops at its rule's max — or, for `ask` rules, a decision the drive loop makes (`deciding`).
321
+ */
322
+ async function advance(run, stepId, state) {
323
+ const outcome = followRules(run.definition, stepId, await flowValues(run, state), state.loops);
324
+ if (outcome.kind === "decide") {
325
+ state.deciding = stepId;
326
+ return stepId;
327
+ }
328
+ return taken(run.definition, stepId, outcome.rule, outcome.cursor, state);
329
+ }
330
+ function taken(def, stepId, rule, cursor, state) {
331
+ const goto = rule === null ? null : stepOf(def, stepId)?.next?.[rule]?.goto;
332
+ if (goto) {
333
+ const key = loopKey(stepId, goto);
334
+ state.loops = { ...state.loops, [key]: (state.loops?.[key] ?? 0) + 1 };
335
+ }
336
+ return cursor;
337
+ }
338
+ /** What a decision about the run reads: the person's answers and what the steps made so far. */
339
+ function runSummary(run, state) {
340
+ const results = {};
341
+ for (const step of run.definition.steps) {
342
+ const out = state.outputs[step.id];
343
+ const text = out?.text ?? (out?.json === undefined ? "" : JSON.stringify(out.json));
344
+ if (text) {
345
+ results[step.title] = text.slice(0, 6000);
346
+ }
347
+ }
348
+ return {
349
+ answers: answersAsText({ def: run.definition, state, brand: {} }),
350
+ results,
351
+ };
352
+ }
353
+ /**
354
+ * The decision over a step's open `ask` rules: one choice among their statements and "none".
355
+ * Kept in the run; a pick that does not lead clearly (where there are probabilities) is none.
356
+ */
357
+ async function decideBranch(run, step, state, signal) {
358
+ const outcome = followRules(run.definition, step.id, {}, state.loops);
359
+ const rules = outcome.kind === "decide" ? outcome.rules : [];
360
+ const criteria = {};
361
+ for (const i of rules) {
362
+ criteria[`r${i}`] = step.next?.[i]?.ask ?? "";
363
+ }
364
+ criteria.none = "None of these clearly holds: go on with the next step.";
365
+ await emitEvent(run.id, step.id, "info", { code: "deciding" });
366
+ const decision = await decide({
367
+ state: runSummary(run, state),
368
+ questions: {
369
+ next: {
370
+ type: "choice",
371
+ instructions: "Which statement is true of this run? Answer none unless one clearly holds.",
372
+ criteria,
373
+ },
374
+ },
375
+ call: { runId: run.id, stepId: step.id },
376
+ signal,
377
+ charge: (usd) => addLocalCost(run.id, step.id, usd),
378
+ });
379
+ const answer = decision.answers.next;
380
+ const margin = marginOf(answer);
381
+ const pick = margin !== null && margin < BRANCH_MARGIN ? "none" : answer.choice;
382
+ const rule = pick === "none" ? null : Number(pick.slice(1));
383
+ state.decisions = {
384
+ ...state.decisions,
385
+ [step.id]: {
386
+ rule,
387
+ source: decision.source,
388
+ ...(answer.probabilities ? { probabilities: answer.probabilities } : {}),
389
+ at: new Date().toISOString(),
390
+ },
391
+ };
392
+ delete state.deciding;
393
+ if (rule === null) {
394
+ const otherwise = otherwiseOf(run.definition, step.id, state.loops);
395
+ return taken(run.definition, step.id, otherwise.rule, otherwise.cursor, state);
396
+ }
397
+ const goto = step.next?.[rule]?.goto ?? "end";
398
+ return taken(run.definition, step.id, rule, goto === "end" ? null : goto, state);
399
+ }
400
+ /**
401
+ * The decided fields (`ask`, `group`) of a page a decision keeps, in one call as the run reaches
402
+ * the page. A field whose condition on earlier data already fails is not asked about. With the
403
+ * fields always shown, at most MAX_SHOWN_FIELDS appear.
404
+ */
405
+ async function decidePageFields(run, page, state, signal) {
406
+ const values = await flowValues(run, state);
407
+ const own = new Set(page.fields.map((f) => f.id));
408
+ const possible = page.fields.filter((f) => isDecidedField(f) &&
409
+ whenHolds(conditionsOf(f.when).filter((c) => !own.has(c.field)), values));
410
+ const questions = {};
411
+ for (const f of possible.filter((f) => f.ask)) {
412
+ questions[`f_${f.id}`] = {
413
+ type: "noul",
414
+ instructions: `Should the page ask "${f.label}"? It is asked when this holds: ${f.ask}`,
415
+ };
416
+ }
417
+ for (const g of page.groups ?? []) {
418
+ const members = possible.filter((f) => f.group === g.id);
419
+ if (members.length) {
420
+ questions[`g_${g.id}`] = {
421
+ type: "choice",
422
+ instructions: g.instructions,
423
+ criteria: {
424
+ ...Object.fromEntries(members.map((f) => [f.id, f.help ? `${f.label} — ${f.help}` : f.label])),
425
+ ...(g.optional ? { none: "None of these." } : {}),
426
+ },
427
+ };
428
+ }
429
+ }
430
+ if (!Object.keys(questions).length) {
431
+ return [];
432
+ }
433
+ await emitEvent(run.id, page.id, "info", { code: "choosingFields" });
434
+ const decision = await decide({
435
+ state: runSummary(run, state),
436
+ questions,
437
+ call: { runId: run.id, stepId: page.id },
438
+ signal,
439
+ charge: (usd) => addLocalCost(run.id, page.id, usd),
440
+ });
441
+ const kept = new Set();
442
+ for (const [name, answer] of Object.entries(decision.answers)) {
443
+ if (answer.type === "noul" && answer.noul >= 0.5) {
444
+ kept.add(name.slice(2));
445
+ }
446
+ else if (answer.type === "choice" && answer.choice !== "none") {
447
+ kept.add(answer.choice);
448
+ }
449
+ }
450
+ const room = MAX_SHOWN_FIELDS - page.fields.filter((f) => !isDecidedField(f)).length;
451
+ return page.fields
452
+ .filter((f) => isDecidedField(f) && kept.has(f.id))
453
+ .slice(0, Math.max(0, room))
454
+ .map((f) => f.id);
455
+ }
274
456
  async function drive(runId, signal) {
275
457
  await clearStaleAsk(runId);
276
458
  while (!signal.aborted) {
@@ -279,11 +461,59 @@ async function drive(runId, signal) {
279
461
  return;
280
462
  }
281
463
  const def = run.definition;
464
+ // A step's `ask` branches wait on a decision before the run goes on.
465
+ const deciding = run.state.deciding ? stepOf(def, run.state.deciding) : undefined;
466
+ if (deciding) {
467
+ const state = structuredClone(run.state);
468
+ try {
469
+ const cursor = await decideBranch(run, deciding, state, signal);
470
+ if (signal.aborted) {
471
+ return;
472
+ }
473
+ await syncRunCost(runId);
474
+ await updateRun(runId, { state, cursor, status: cursor ? "running" : "done", error: null });
475
+ }
476
+ catch (err) {
477
+ if (signal.aborted) {
478
+ return;
479
+ }
480
+ console.error(`[run ${runId} deciding ${deciding.id}]`, err);
481
+ const message = friendly(err);
482
+ await emitEvent(runId, deciding.id, "error", message);
483
+ await updateRun(runId, { status: "failed", error: message });
484
+ return;
485
+ }
486
+ continue;
487
+ }
282
488
  const step = stepOf(def, run.cursor);
283
489
  if (!step) {
284
490
  await updateRun(runId, { status: "done" });
285
491
  return;
286
492
  }
493
+ // A page whose fields a decision picks: picked once, as the run reaches it.
494
+ if (step.type === "page" && step.fields.some(isDecidedField) && !run.state.pages?.[step.id]) {
495
+ const state = structuredClone(run.state);
496
+ try {
497
+ const shown = await decidePageFields(run, step, state, signal);
498
+ if (signal.aborted) {
499
+ return;
500
+ }
501
+ state.pages = { ...state.pages, [step.id]: { shown } };
502
+ await syncRunCost(runId);
503
+ await updateRun(runId, { state, error: null });
504
+ }
505
+ catch (err) {
506
+ if (signal.aborted) {
507
+ return;
508
+ }
509
+ console.error(`[run ${runId} fields of ${step.id}]`, err);
510
+ const message = friendly(err);
511
+ await emitEvent(runId, step.id, "error", message);
512
+ await updateRun(runId, { status: "failed", error: message });
513
+ return;
514
+ }
515
+ continue;
516
+ }
287
517
  if (step.type === "result") {
288
518
  await updateRun(runId, { status: "done" });
289
519
  return;
@@ -312,7 +542,7 @@ async function drive(runId, signal) {
312
542
  state.outputs[step.id] = output;
313
543
  delete state.notes[step.id];
314
544
  delete state.redo?.[step.id];
315
- const cursor = nextStepId(def, step.id, branchValues(state.values, state.outputs));
545
+ const cursor = await advance(run, step.id, state);
316
546
  await updateRun(runId, { state, cursor, status: cursor ? "running" : "done", error: null });
317
547
  await emitEvent(runId, step.id, "step_done", step.title);
318
548
  }
@@ -363,7 +593,7 @@ export async function submitPage(runId, stepId, input) {
363
593
  if (step?.type !== "page") {
364
594
  throw new RunConflict("Not a page");
365
595
  }
366
- const { values, errors } = readPageInput(step, input);
596
+ const { values, errors } = readPageInput(step, input, await pageContext(run, step));
367
597
  // An answer that leads to a step without a model here would stop the run there, later.
368
598
  const closed = await closedChoices(run.definition, step, run.state.values);
369
599
  for (const [id, choice] of Object.entries(closed)) {
@@ -408,8 +638,13 @@ export async function submitPage(runId, stepId, input) {
408
638
  delete state.values[field.id];
409
639
  }
410
640
  }
641
+ // Later pages decide their fields again on what is answered now.
642
+ const at = run.definition.steps.indexOf(step);
643
+ for (const later of run.definition.steps.slice(at + 1)) {
644
+ delete state.pages?.[later.id];
645
+ }
411
646
  state.history.push(stepId);
412
- const cursor = nextStepId(run.definition, stepId, branchValues(state.values, state.outputs));
647
+ const cursor = await advance(run, stepId, state);
413
648
  await updateRun(runId, { state, cursor, status: "running", error: null });
414
649
  kick(runId);
415
650
  }
@@ -427,7 +662,7 @@ export async function reviewStep(runId, stepId, action) {
427
662
  }
428
663
  }
429
664
  state.history.push(stepId);
430
- const cursor = nextStepId(run.definition, stepId, branchValues(state.values, state.outputs));
665
+ const cursor = await advance(run, stepId, state);
431
666
  await updateRun(runId, { state, cursor, status: "running", error: null });
432
667
  kick(runId);
433
668
  return;
@@ -458,6 +693,8 @@ export async function goBack(runId) {
458
693
  if (!previous) {
459
694
  throw new RunConflict("Kein vorheriger Schritt.");
460
695
  }
696
+ // A decision that failed belongs to the step left behind.
697
+ delete state.deciding;
461
698
  await updateRun(runId, { state, cursor: previous, status: "waiting_input", error: null });
462
699
  }
463
700
  export async function retry(runId) {
@@ -578,6 +815,15 @@ export async function runView(run, brand) {
578
815
  step,
579
816
  values: run.state.values,
580
817
  prefill: step?.type === "page" ? prefillOf(step, run, closed) : {},
818
+ ...(step?.type === "page" ? viewContext(await pageContext(run, step)) : {}),
819
+ ...(run.mode === "test" && run.state.decisions
820
+ ? {
821
+ decisions: Object.fromEntries(Object.entries(run.state.decisions).map(([id, d]) => [
822
+ id,
823
+ { ...d, over: def.steps.find((s) => s.id === id)?.next ?? [] },
824
+ ])),
825
+ }
826
+ : {}),
581
827
  closed,
582
828
  outputs: Object.fromEntries(shownIds.map((id) => [id, run.state.outputs[id]]).filter(([, o]) => o)),
583
829
  shown,