|
5 | 5 | * |
6 | 6 | * Config contracts for the remaining flat builtins — the CRUD quartet |
7 | 7 | * (`get_record` / `create_record` / `update_record` / `delete_record`), |
8 | | - * `screen`, `map` (#4045) and, since #14149, `assignment`'s value contract. |
| 8 | + * `screen`, `map` (#4045), since #14149 `assignment`'s value contract and, |
| 9 | + * since #14945, the structural `end` node's outcome (`EndConfigSchema`, the |
| 10 | + * one contract here the FLOW PARSE applies rather than an executor). |
9 | 11 | * Sibling of `io-node-config.zod.ts` (notify / http) and `control-flow.zod.ts` |
10 | 12 | * (loop / parallel / try_catch). |
11 | 13 | * |
@@ -475,6 +477,118 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({ |
475 | 477 | export type ScreenConfig = z.input<typeof ScreenConfigSchema>; |
476 | 478 | export type ScreenConfigParsed = z.infer<typeof ScreenConfigSchema>; |
477 | 479 |
|
| 480 | +// ─── end ───────────────────────────────────────────────────────────── |
| 481 | + |
| 482 | +/** |
| 483 | + * `end` node config — how the run ENDS (#14945). |
| 484 | + * |
| 485 | + * `end` is a structural node (`FLOW_STRUCTURAL_NODE_TYPES`): the engine |
| 486 | + * terminates the run on reaching it with no registered executor, so — unlike |
| 487 | + * every other contract in this module — nothing `parse()`s this shape at |
| 488 | + * execute time and no descriptor `configSchema` closes it at `registerFlow()`. |
| 489 | + * The one door an `end` node's config passes through is the flow parse itself, |
| 490 | + * which is why `FlowNodeSchema` (flow.zod.ts) applies this contract to every |
| 491 | + * `type: 'end'` node it parses, at any region depth, and writes the parsed |
| 492 | + * (defaulted) config back. |
| 493 | + * |
| 494 | + * ## `outcome` — the terminal state the run records |
| 495 | + * |
| 496 | + * Every terminal of a flow used to be "completed": a flow could say *do this* |
| 497 | + * but not *refuse this, and say why, for which record*. The only channel that |
| 498 | + * interpolated per-record text was a `screen` node's `description`, and a |
| 499 | + * message-only screen is an INPUT step wearing a notice's clothes — it renders |
| 500 | + * Submit, and submitting resumes the run to `end`, whose runner toasts |
| 501 | + * `Flow "…" completed` at a user who was just told "this is refused" (the |
| 502 | + * hotcrm lead-conversion refusal, hotcrm#1288 / hotcrm#1555, is the measured |
| 503 | + * case). |
| 504 | + * |
| 505 | + * Maintainer ruling 2026-09-05 (option 2′): the refusal is a first-class |
| 506 | + * OUTCOME of the existing terminal node, not a second terminal node type — |
| 507 | + * `outcome: 'refused'` with an interpolated `message`. A refused end is a |
| 508 | + * terminal state: the run records `refused` (`ExecutionStatus`, distinct from |
| 509 | + * `failed` — a refusal is a successful evaluation that says no) together with |
| 510 | + * the rendered message (`ExecutionLog.refusalMessage`), it is never resumed, |
| 511 | + * and a runner renders the message with Close only — no Submit, no completion |
| 512 | + * toast; the invoking action's `successMessage` stays suppressed. The halves |
| 513 | + * land in sequence: this contract, then the engine's `end` handling (stamping |
| 514 | + * the outcome, persisting the rendered message), then the runner. |
| 515 | + * |
| 516 | + * ## `message` — required by a refusal, refused by a completion |
| 517 | + * |
| 518 | + * `message` is a `{token}` template rendered at the engine's existing |
| 519 | + * interpolation points, exactly as a `screen` node's `description` is — |
| 520 | + * `'Refused: {record.name} is a confirmed duplicate'` yields per-record text at |
| 521 | + * run time. Two refinements keep the pair honest, in both directions: |
| 522 | + * |
| 523 | + * - `outcome: 'refused'` with no `message` is REFUSED — a refusal without |
| 524 | + * text is exactly the shape this contract exists to make expressible, and |
| 525 | + * an author who omits it ships a refusal nobody can explain. |
| 526 | + * - `message` with `outcome: 'completed'` (or omitted) is REFUSED — a |
| 527 | + * completion renders nothing, so the key would be a silent no-op: the kind |
| 528 | + * of key an AI author sets, sees no error for, and reports "done" over. |
| 529 | + */ |
| 530 | +export const EndConfigSchema = lazySchema(() => strictObject({ |
| 531 | + surface: 'this end node config', |
| 532 | + history: |
| 533 | + 'Until this shape was declared, an `end` node had no config contract at all — any key was accepted at parse ' |
| 534 | + + 'and ignored at run time, so a refusal an author wrote here shipped as a plain completion.', |
| 535 | + aliases: { |
| 536 | + // The words a refusal arrives spelled in — `reason` / `text` from prose, |
| 537 | + // `description` from the screen node an author migrates the refusal OUT |
| 538 | + // of (the card's own workaround), `status` / `result` for the outcome. |
| 539 | + reason: 'message', |
| 540 | + text: 'message', |
| 541 | + description: 'message', |
| 542 | + status: 'outcome', |
| 543 | + result: 'outcome', |
| 544 | + }, |
| 545 | + guidance: { |
| 546 | + title: |
| 547 | + 'An `end` node has no heading — a runner shows `message` alone under the flow\'s label. Put the text in ' |
| 548 | + + '`message`; a `title` belongs to a `screen` node.', |
| 549 | + }, |
| 550 | +}, { |
| 551 | + /** Terminal state the run records: `completed` (default) or `refused`. */ |
| 552 | + outcome: z.enum(['completed', 'refused']).default('completed').describe( |
| 553 | + 'How the run ends when it reaches this node. `completed` (the default) is the ordinary terminal. ' |
| 554 | + + '`refused` is a first-class refusal: the run records `refused` — distinct from `failed`, a refusal is a ' |
| 555 | + + 'successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows ' |
| 556 | + + 'the message with Close only: no Submit, no completion toast.', |
| 557 | + ), |
| 558 | + /** Why the run was refused. Interpolates `{token}` like a screen `description`. */ |
| 559 | + message: z.string().min(1).optional().describe( |
| 560 | + 'Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen ' |
| 561 | + + '`description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; ' |
| 562 | + + 'refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op.', |
| 563 | + ), |
| 564 | +}).superRefine((config, ctx) => { |
| 565 | + if (config.outcome === 'refused') { |
| 566 | + if (config.message === undefined) { |
| 567 | + ctx.addIssue({ |
| 568 | + code: 'custom', |
| 569 | + path: ['message'], |
| 570 | + message: |
| 571 | + "`outcome: 'refused'` requires a `message` — a refusal with no text is the shape this contract exists to " |
| 572 | + + 'replace (a screen pretending to be a notice). Say why, as a `{token}` template so the text names the ' |
| 573 | + + "record: `message: 'Refused: {record.name} is a confirmed duplicate'`.", |
| 574 | + }); |
| 575 | + } |
| 576 | + return; |
| 577 | + } |
| 578 | + if (config.message !== undefined) { |
| 579 | + ctx.addIssue({ |
| 580 | + code: 'custom', |
| 581 | + path: ['message'], |
| 582 | + message: |
| 583 | + "`message` is only rendered when `outcome` is 'refused' — on a completed end nothing shows it, so the key " |
| 584 | + + "would be a silent no-op. Either set `outcome: 'refused'` or delete `message`.", |
| 585 | + }); |
| 586 | + } |
| 587 | +})); |
| 588 | + |
| 589 | +export type EndConfig = z.input<typeof EndConfigSchema>; |
| 590 | +export type EndConfigParsed = z.infer<typeof EndConfigSchema>; |
| 591 | + |
478 | 592 | // ─── map ───────────────────────────────────────────────────────────── |
479 | 593 |
|
480 | 594 | /** |
|
0 commit comments