makersclaw 0.7.0 → 0.8.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/README.md +17 -4
- package/package.json +1 -1
- package/r/activity-log.json +52 -0
- package/r/agent-changes.json +51 -0
- package/r/agent-chat.json +53 -0
- package/r/agent-column.json +85 -0
- package/r/agent-dock.json +57 -0
- package/r/agent-kit.json +49 -0
- package/r/agent-marks.json +49 -0
- package/r/agent-pane.json +93 -0
- package/r/ai-action.json +56 -0
- package/r/annotate.json +4 -4
- package/r/approval-request.json +2 -2
- package/r/approval.json +54 -0
- package/r/ask-bar.json +50 -0
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +66 -0
- package/r/automation-list.json +57 -0
- package/r/automation-model.json +118 -0
- package/r/automations-screen.json +71 -0
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +5 -5
- package/r/chat-pane.json +84 -0
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/connect-card.json +51 -0
- package/r/contact-section.json +2 -2
- package/r/core.json +18 -6
- package/r/cost.json +51 -0
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +60 -0
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/first-run.json +63 -0
- package/r/fix-note.json +57 -0
- package/r/flow-canvas.json +3 -3
- package/r/flow-editor.json +79 -0
- package/r/footer.json +3 -3
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/library-shelf.json +56 -0
- package/r/map.json +2 -2
- package/r/margin-notes.json +67 -0
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/needs-you.json +55 -0
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/pace-bar.json +69 -0
- package/r/page-builder.json +3 -3
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-grid.json +52 -0
- package/r/record-notes.json +4 -4
- package/r/registry.json +4089 -1510
- package/r/run-history.json +52 -0
- package/r/run-view.json +80 -0
- package/r/schedule-card.json +54 -0
- package/r/schedule-engine.json +79 -0
- package/r/schedule-gantt.json +83 -0
- package/r/schedule-ruler.json +64 -0
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +3 -3
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +3 -3
- package/r/source-receipt.json +52 -0
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +53 -0
- package/r/step-settings.json +72 -0
- package/r/suggestion.json +58 -0
- package/r/sum-bar.json +64 -0
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/tier-map.json +67 -0
- package/r/time-grid.json +73 -0
- package/r/time-strip.json +64 -0
- package/r/top-bar.json +66 -0
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +61 -0
- package/r/whiteboard.json +4 -4
- package/r/workflow-view.json +55 -0
- package/registry/blocks/activity-log/README.md +112 -0
- package/registry/blocks/activity-log/activity-log.jsx +351 -0
- package/registry/blocks/activity-log/block.json +33 -0
- package/registry/blocks/activity-log/demo.jsx +48 -0
- package/registry/blocks/activity-log/index.js +4 -0
- package/registry/blocks/agent-changes/README.md +94 -0
- package/registry/blocks/agent-changes/agent-changes.jsx +118 -0
- package/registry/blocks/agent-changes/block.json +28 -0
- package/registry/blocks/agent-changes/demo.jsx +39 -0
- package/registry/blocks/agent-changes/index.js +3 -0
- package/registry/blocks/agent-chat/README.md +145 -0
- package/registry/blocks/agent-chat/agent-chat.jsx +415 -0
- package/registry/blocks/agent-chat/block.json +30 -0
- package/registry/blocks/agent-chat/demo.jsx +165 -0
- package/registry/blocks/agent-chat/index.js +3 -0
- package/registry/blocks/agent-column/README.md +197 -0
- package/registry/blocks/agent-column/agent-column.jsx +492 -0
- package/registry/blocks/agent-column/block.json +67 -0
- package/registry/blocks/agent-column/column-picker.jsx +163 -0
- package/registry/blocks/agent-column/demo.jsx +50 -0
- package/registry/blocks/agent-column/index.js +7 -0
- package/registry/blocks/agent-column/model.js +164 -0
- package/registry/blocks/agent-column/receipt.jsx +483 -0
- package/registry/blocks/agent-column/spreadsheet-type.jsx +24 -0
- package/registry/blocks/agent-dock/README.md +210 -0
- package/registry/blocks/agent-dock/agent-dock.jsx +356 -0
- package/registry/blocks/agent-dock/block.json +47 -0
- package/registry/blocks/agent-dock/demo.jsx +153 -0
- package/registry/blocks/agent-dock/dock-model.js +118 -0
- package/registry/blocks/agent-dock/index.js +5 -0
- package/registry/blocks/agent-kit/README.md +73 -0
- package/registry/blocks/agent-kit/agent-kit.jsx +86 -0
- package/registry/blocks/agent-kit/block.json +10 -0
- package/registry/blocks/agent-kit/demo.jsx +35 -0
- package/registry/blocks/agent-kit/index.js +3 -0
- package/registry/blocks/agent-marks/README.md +80 -0
- package/registry/blocks/agent-marks/agent-marks.jsx +133 -0
- package/registry/blocks/agent-marks/block.json +27 -0
- package/registry/blocks/agent-marks/demo.jsx +49 -0
- package/registry/blocks/agent-marks/index.js +3 -0
- package/registry/blocks/agent-pane/README.md +225 -0
- package/registry/blocks/agent-pane/agent-pane.jsx +244 -0
- package/registry/blocks/agent-pane/block.json +41 -0
- package/registry/blocks/agent-pane/composer.jsx +6 -0
- package/registry/blocks/agent-pane/demo.jsx +233 -0
- package/registry/blocks/agent-pane/dock-context.js +10 -0
- package/registry/blocks/agent-pane/index.js +4 -0
- package/registry/blocks/agent-pane/prose.jsx +84 -0
- package/registry/blocks/agent-pane/questions.jsx +134 -0
- package/registry/blocks/agent-pane/transcript.jsx +268 -0
- package/registry/blocks/agent-pane/work.jsx +368 -0
- package/registry/blocks/ai-action/README.md +94 -0
- package/registry/blocks/ai-action/ai-action.jsx +188 -0
- package/registry/blocks/ai-action/block.json +34 -0
- package/registry/blocks/ai-action/demo.jsx +93 -0
- package/registry/blocks/ai-action/index.js +3 -0
- package/registry/blocks/annotate/README.md +14 -11
- package/registry/blocks/approval/README.md +145 -0
- package/registry/blocks/approval/approval.jsx +163 -0
- package/registry/blocks/approval/block.json +31 -0
- package/registry/blocks/approval/demo.jsx +121 -0
- package/registry/blocks/approval/index.js +3 -0
- package/registry/blocks/ask-bar/README.md +105 -0
- package/registry/blocks/ask-bar/ask-bar.jsx +166 -0
- package/registry/blocks/ask-bar/block.json +26 -0
- package/registry/blocks/ask-bar/demo.jsx +78 -0
- package/registry/blocks/ask-bar/index.js +3 -0
- package/registry/blocks/automation-editor/README.md +291 -0
- package/registry/blocks/automation-editor/automation-editor.jsx +656 -0
- package/registry/blocks/automation-editor/block.json +39 -0
- package/registry/blocks/automation-editor/demo.jsx +284 -0
- package/registry/blocks/automation-editor/index.js +4 -0
- package/registry/blocks/automation-editor/run-inputs.jsx +82 -0
- package/registry/blocks/automation-list/README.md +129 -0
- package/registry/blocks/automation-list/automation-list.jsx +476 -0
- package/registry/blocks/automation-list/block.json +35 -0
- package/registry/blocks/automation-list/demo.jsx +67 -0
- package/registry/blocks/automation-list/index.js +3 -0
- package/registry/blocks/automation-model/README.md +109 -0
- package/registry/blocks/automation-model/block.json +135 -0
- package/registry/blocks/automation-model/catalog.js +256 -0
- package/registry/blocks/automation-model/check.js +161 -0
- package/registry/blocks/automation-model/demo.jsx +42 -0
- package/registry/blocks/automation-model/edit.js +262 -0
- package/registry/blocks/automation-model/fixes.js +287 -0
- package/registry/blocks/automation-model/glyph.jsx +13 -0
- package/registry/blocks/automation-model/graph.js +410 -0
- package/registry/blocks/automation-model/index.js +33 -0
- package/registry/blocks/automation-model/overlay.js +175 -0
- package/registry/blocks/automation-model/parts.jsx +94 -0
- package/registry/blocks/automation-model/schedule.js +53 -0
- package/registry/blocks/automation-model/templates.js +128 -0
- package/registry/blocks/automation-model/values.js +29 -0
- package/registry/blocks/automation-model/words.js +439 -0
- package/registry/blocks/automations-screen/README.md +207 -0
- package/registry/blocks/automations-screen/automations-screen.jsx +452 -0
- package/registry/blocks/automations-screen/block.json +47 -0
- package/registry/blocks/automations-screen/demo.jsx +364 -0
- package/registry/blocks/automations-screen/index.js +4 -0
- package/registry/blocks/automations-screen/screen-model.js +129 -0
- package/registry/blocks/chart/cartesian.jsx +1 -1
- package/registry/blocks/chart/core.jsx +33 -23
- package/registry/blocks/chart/pie.jsx +1 -1
- package/registry/blocks/chat/README.md +4 -1
- package/registry/blocks/chat/demo.jsx +2 -2
- package/registry/blocks/chat-pane/README.md +143 -0
- package/registry/blocks/chat-pane/block.json +48 -0
- package/registry/blocks/chat-pane/chat-attach.js +166 -0
- package/registry/blocks/chat-pane/chat-composer.jsx +195 -0
- package/registry/blocks/chat-pane/chat-context.js +17 -0
- package/registry/blocks/chat-pane/chat-model.js +43 -0
- package/registry/blocks/chat-pane/chat-pane.jsx +398 -0
- package/registry/blocks/chat-pane/chats-list.jsx +52 -0
- package/registry/blocks/chat-pane/demo.jsx +103 -0
- package/registry/blocks/chat-pane/index.js +9 -0
- package/registry/blocks/connect-card/README.md +99 -0
- package/registry/blocks/connect-card/block.json +10 -0
- package/registry/blocks/connect-card/connect-card.jsx +103 -0
- package/registry/blocks/connect-card/demo.jsx +49 -0
- package/registry/blocks/connect-card/index.js +3 -0
- package/registry/blocks/cost/README.md +115 -0
- package/registry/blocks/cost/block.json +10 -0
- package/registry/blocks/cost/cost.jsx +176 -0
- package/registry/blocks/cost/demo.jsx +66 -0
- package/registry/blocks/cost/index.js +3 -0
- package/registry/blocks/data-picker/README.md +81 -0
- package/registry/blocks/data-picker/block.json +38 -0
- package/registry/blocks/data-picker/data-picker.jsx +210 -0
- package/registry/blocks/data-picker/demo.jsx +34 -0
- package/registry/blocks/data-picker/fields.js +89 -0
- package/registry/blocks/data-picker/index.js +4 -0
- package/registry/blocks/data-table/demo.jsx +1 -1
- package/registry/blocks/first-run/README.md +118 -0
- package/registry/blocks/first-run/block.json +11 -0
- package/registry/blocks/first-run/demo.jsx +88 -0
- package/registry/blocks/first-run/first-run-model.js +51 -0
- package/registry/blocks/first-run/first-run.jsx +156 -0
- package/registry/blocks/first-run/index.js +5 -0
- package/registry/blocks/first-run/probe-rules.js +26 -0
- package/registry/blocks/fix-note/README.md +79 -0
- package/registry/blocks/fix-note/block.json +35 -0
- package/registry/blocks/fix-note/demo.jsx +30 -0
- package/registry/blocks/fix-note/fix-note.jsx +235 -0
- package/registry/blocks/fix-note/index.js +3 -0
- package/registry/blocks/flow-canvas/README.md +15 -8
- package/registry/blocks/flow-editor/README.md +108 -0
- package/registry/blocks/flow-editor/block.json +44 -0
- package/registry/blocks/flow-editor/demo.jsx +115 -0
- package/registry/blocks/flow-editor/flow-canvas.jsx +568 -0
- package/registry/blocks/flow-editor/flow-editor.jsx +154 -0
- package/registry/blocks/flow-editor/index.js +6 -0
- package/registry/blocks/flow-editor/minimap.jsx +78 -0
- package/registry/blocks/flow-editor/outline.jsx +134 -0
- package/registry/blocks/footer/README.md +3 -2
- package/registry/blocks/library-shelf/README.md +117 -0
- package/registry/blocks/library-shelf/block.json +28 -0
- package/registry/blocks/library-shelf/demo.jsx +68 -0
- package/registry/blocks/library-shelf/index.js +3 -0
- package/registry/blocks/library-shelf/library-shelf.jsx +211 -0
- package/registry/blocks/library-shelf/probe-rules.js +15 -0
- package/registry/blocks/margin-notes/README.md +222 -0
- package/registry/blocks/margin-notes/block.json +10 -0
- package/registry/blocks/margin-notes/demo.jsx +144 -0
- package/registry/blocks/margin-notes/index.js +20 -0
- package/registry/blocks/margin-notes/margin-model.js +224 -0
- package/registry/blocks/margin-notes/margin-notes.jsx +465 -0
- package/registry/blocks/mind-map/README.md +4 -2
- package/registry/blocks/needs-you/README.md +177 -0
- package/registry/blocks/needs-you/block.json +34 -0
- package/registry/blocks/needs-you/demo.jsx +143 -0
- package/registry/blocks/needs-you/index.js +3 -0
- package/registry/blocks/needs-you/needs-you.jsx +441 -0
- package/registry/blocks/org-chart/README.md +4 -2
- package/registry/blocks/pace-bar/README.md +230 -0
- package/registry/blocks/pace-bar/block.json +10 -0
- package/registry/blocks/pace-bar/demo.jsx +255 -0
- package/registry/blocks/pace-bar/index.js +22 -0
- package/registry/blocks/pace-bar/pace-bar.jsx +461 -0
- package/registry/blocks/pace-bar/pace-model.js +288 -0
- package/registry/blocks/page-builder/README.md +1 -1
- package/registry/blocks/page-builder/demo.jsx +3 -3
- package/registry/blocks/pdf-viewer/README.md +4 -1
- package/registry/blocks/record-grid/README.md +100 -0
- package/registry/blocks/record-grid/block.json +28 -0
- package/registry/blocks/record-grid/demo.jsx +43 -0
- package/registry/blocks/record-grid/index.js +3 -0
- package/registry/blocks/record-grid/record-grid.jsx +251 -0
- package/registry/blocks/run-history/README.md +138 -0
- package/registry/blocks/run-history/block.json +10 -0
- package/registry/blocks/run-history/demo.jsx +74 -0
- package/registry/blocks/run-history/index.js +3 -0
- package/registry/blocks/run-history/run-history.jsx +218 -0
- package/registry/blocks/run-view/README.md +174 -0
- package/registry/blocks/run-view/block.json +50 -0
- package/registry/blocks/run-view/demo.jsx +286 -0
- package/registry/blocks/run-view/index.js +5 -0
- package/registry/blocks/run-view/run-controls.jsx +266 -0
- package/registry/blocks/run-view/run-view.jsx +236 -0
- package/registry/blocks/run-view/runs-list.jsx +99 -0
- package/registry/blocks/schedule-card/README.md +121 -0
- package/registry/blocks/schedule-card/block.json +10 -0
- package/registry/blocks/schedule-card/demo.jsx +82 -0
- package/registry/blocks/schedule-card/index.js +3 -0
- package/registry/blocks/schedule-card/schedule-card.jsx +136 -0
- package/registry/blocks/schedule-engine/README.md +137 -0
- package/registry/blocks/schedule-engine/block.json +10 -0
- package/registry/blocks/schedule-engine/demo.jsx +82 -0
- package/registry/blocks/schedule-engine/engine.js +769 -0
- package/registry/blocks/schedule-engine/index.js +9 -0
- package/registry/blocks/schedule-engine/ops.js +71 -0
- package/registry/blocks/schedule-engine/place.js +294 -0
- package/registry/blocks/schedule-engine/tree.js +79 -0
- package/registry/blocks/schedule-engine/waits.js +80 -0
- package/registry/blocks/schedule-gantt/README.md +159 -0
- package/registry/blocks/schedule-gantt/block.json +10 -0
- package/registry/blocks/schedule-gantt/demo.jsx +223 -0
- package/registry/blocks/schedule-gantt/gantt-model.js +219 -0
- package/registry/blocks/schedule-gantt/geometry.js +176 -0
- package/registry/blocks/schedule-gantt/index.js +26 -0
- package/registry/blocks/schedule-gantt/schedule-gantt.jsx +882 -0
- package/registry/blocks/schedule-gantt/sheet.jsx +372 -0
- package/registry/blocks/schedule-gantt/track.jsx +190 -0
- package/registry/blocks/schedule-ruler/README.md +148 -0
- package/registry/blocks/schedule-ruler/block.json +10 -0
- package/registry/blocks/schedule-ruler/demo.jsx +181 -0
- package/registry/blocks/schedule-ruler/index.js +21 -0
- package/registry/blocks/schedule-ruler/ruler-model.js +239 -0
- package/registry/blocks/schedule-ruler/schedule-ruler.jsx +548 -0
- package/registry/blocks/sidebar/README.md +23 -13
- package/registry/blocks/sketch-canvas/README.md +15 -9
- package/registry/blocks/source-receipt/README.md +97 -0
- package/registry/blocks/source-receipt/block.json +30 -0
- package/registry/blocks/source-receipt/demo.jsx +71 -0
- package/registry/blocks/source-receipt/index.js +3 -0
- package/registry/blocks/source-receipt/source-receipt.jsx +107 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +15 -1
- package/registry/blocks/step-picker/README.md +78 -0
- package/registry/blocks/step-picker/block.json +31 -0
- package/registry/blocks/step-picker/demo.jsx +23 -0
- package/registry/blocks/step-picker/index.js +3 -0
- package/registry/blocks/step-picker/step-picker.jsx +144 -0
- package/registry/blocks/step-settings/README.md +100 -0
- package/registry/blocks/step-settings/block.json +44 -0
- package/registry/blocks/step-settings/demo.jsx +93 -0
- package/registry/blocks/step-settings/fields.jsx +58 -0
- package/registry/blocks/step-settings/flow-settings.jsx +115 -0
- package/registry/blocks/step-settings/index.js +5 -0
- package/registry/blocks/step-settings/step-settings.jsx +290 -0
- package/registry/blocks/suggestion/README.md +171 -0
- package/registry/blocks/suggestion/block.json +40 -0
- package/registry/blocks/suggestion/demo.jsx +91 -0
- package/registry/blocks/suggestion/index.js +3 -0
- package/registry/blocks/suggestion/suggestion.jsx +436 -0
- package/registry/blocks/sum-bar/README.md +122 -0
- package/registry/blocks/sum-bar/block.json +10 -0
- package/registry/blocks/sum-bar/demo.jsx +110 -0
- package/registry/blocks/sum-bar/index.js +5 -0
- package/registry/blocks/sum-bar/sum-bar.jsx +209 -0
- package/registry/blocks/sum-bar/sum-model.js +126 -0
- package/registry/blocks/tier-map/README.md +217 -0
- package/registry/blocks/tier-map/block.json +10 -0
- package/registry/blocks/tier-map/demo.jsx +222 -0
- package/registry/blocks/tier-map/index.js +24 -0
- package/registry/blocks/tier-map/tier-map.jsx +574 -0
- package/registry/blocks/tier-map/tier-model.js +318 -0
- package/registry/blocks/time-grid/README.md +305 -0
- package/registry/blocks/time-grid/block.json +10 -0
- package/registry/blocks/time-grid/demo.jsx +279 -0
- package/registry/blocks/time-grid/grid-model.js +322 -0
- package/registry/blocks/time-grid/index.js +25 -0
- package/registry/blocks/time-grid/time-grid.jsx +1325 -0
- package/registry/blocks/time-strip/README.md +141 -0
- package/registry/blocks/time-strip/block.json +10 -0
- package/registry/blocks/time-strip/demo.jsx +129 -0
- package/registry/blocks/time-strip/index.js +19 -0
- package/registry/blocks/time-strip/strip-model.js +178 -0
- package/registry/blocks/time-strip/time-strip.jsx +260 -0
- package/registry/blocks/top-bar/README.md +149 -0
- package/registry/blocks/top-bar/block.json +35 -0
- package/registry/blocks/top-bar/demo.jsx +78 -0
- package/registry/blocks/top-bar/index.js +5 -0
- package/registry/blocks/top-bar/probe-rules.js +74 -0
- package/registry/blocks/top-bar/top-bar-model.js +152 -0
- package/registry/blocks/top-bar/top-bar.jsx +244 -0
- package/registry/blocks/trigger-picker/README.md +98 -0
- package/registry/blocks/trigger-picker/block.json +38 -0
- package/registry/blocks/trigger-picker/demo.jsx +59 -0
- package/registry/blocks/trigger-picker/index.js +3 -0
- package/registry/blocks/trigger-picker/trigger-picker.jsx +374 -0
- package/registry/blocks/whiteboard/README.md +20 -14
- package/registry/blocks/workflow-view/README.md +123 -0
- package/registry/blocks/workflow-view/block.json +10 -0
- package/registry/blocks/workflow-view/demo.jsx +90 -0
- package/registry/blocks/workflow-view/index.js +3 -0
- package/registry/blocks/workflow-view/workflow-view.jsx +203 -0
- package/src/cli.mjs +2 -2
- package/src/install.mjs +100 -22
- package/src/registry.mjs +37 -3
- package/templates/next/browser/lib/data.js +6 -1
- package/templates/next/files/README.md +909 -26
- package/templates/next/files/app/shell.jsx +54 -37
- package/templates/next/files/check/automation-format.ts +2103 -0
- package/templates/next/files/check/automation-package.ts +142 -0
- package/templates/next/files/check/run.mjs +1043 -12
- package/templates/next/files/components/ui/field.jsx +17 -4
- package/templates/next/files/components/ui/icon.jsx +13 -0
- package/templates/next/files/components/ui/index.js +3 -1
- package/templates/next/files/components/ui/layout.jsx +382 -0
- package/templates/next/files/components/ui/notice.jsx +70 -0
- package/templates/next/files/components/ui/page.jsx +124 -0
- package/templates/next/files/components/ui/stat.jsx +22 -7
- package/templates/next/files/components/ui/toast.jsx +6 -3
- package/templates/next/files/lib/automations.js +660 -0
- package/templates/next/files/lib/claw.js +244 -5
- package/templates/next/files/lib/door.js +92 -0
- package/templates/next/files/lib/hooks.js +1694 -6
- package/templates/next/files/lib/platform.js +611 -14
- package/templates/next/template.json +22 -3
|
@@ -0,0 +1,2103 @@
|
|
|
1
|
+
// Copied from claw shared/src/automation.ts @ 33de2374. Never edit; refresh with scripts/sync-automation-format.mjs.
|
|
2
|
+
/**
|
|
3
|
+
* Automations: the definition format, its one validator, and the small
|
|
4
|
+
* language steps use to pass data to each other (docs/AUTOMATIONS-PLAN.md,
|
|
5
|
+
* "The definition" and "Data between steps"; decisions A2, A8, A18).
|
|
6
|
+
*
|
|
7
|
+
* An automation is a JSON document, format 1: a trigger and an ordered
|
|
8
|
+
* list of steps. A condition has `then` and `else` lists; a loop has a
|
|
9
|
+
* `steps` list. No arrows, no cycles, no joins (A2): a durable run must
|
|
10
|
+
* resume at "step 4, item 7", a version must merge by step id, and a person
|
|
11
|
+
* must be able to say what it does in one breath.
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "format": 1, "key": "morning-read", "name": "Morning read",
|
|
15
|
+
* "trigger": { "type": "schedule", "cron": "0 7 * * 1-5", "tz": "team" },
|
|
16
|
+
* "inputs": {}, "settings": { "overlap": "skip" },
|
|
17
|
+
* "steps": [
|
|
18
|
+
* { "id": "mail", "kind": "action", "action": "read_mail", "input": { "since": "{{ trigger.at }}" } },
|
|
19
|
+
* { "id": "has", "kind": "condition", "if": "{{ steps.mail.output.result.count > 0 }}",
|
|
20
|
+
* "then": [ { "id": "done", "kind": "stop", "message": "Read {{ steps.mail.output.result.count }}" } ] }
|
|
21
|
+
* ]
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* What is in this file, none of it touching a tenant's code, a clock or a
|
|
25
|
+
* network:
|
|
26
|
+
*
|
|
27
|
+
* 1. The expression language (A8). `{{ ... }}` inside any string of a
|
|
28
|
+
* step. A value that is only one expression keeps its type (a list
|
|
29
|
+
* stays a list); a string that mixes text and expressions is text. No
|
|
30
|
+
* `eval`, no `Function`, no regular expressions from outside: a hand
|
|
31
|
+
* written tokenizer and parser, a whitelist of functions, a work budget
|
|
32
|
+
* and size caps, so a hostile expression stops with a plain error and
|
|
33
|
+
* cannot reach `constructor`, `__proto__` or anything outside its data.
|
|
34
|
+
* 2. `validateAutomation`: every rule of the format, one list of problems
|
|
35
|
+
* each naming its place ("steps[2].input.since"). It runs in the
|
|
36
|
+
* template's check, the store's publish, the engine's save and the
|
|
37
|
+
* Builder's ship.
|
|
38
|
+
* 3. The output-schema check (A8): each step kind declares the shape of
|
|
39
|
+
* its output; a reference to `steps.mail.output.messages` that no such
|
|
40
|
+
* field answers is refused at save, with the step and field named.
|
|
41
|
+
*
|
|
42
|
+
* There is no approval step (A18): `ask` and `approval` are refused with
|
|
43
|
+
* their reason. Layout is view data and never part of a definition.
|
|
44
|
+
*
|
|
45
|
+
* Standalone: no imports, no env, no I/O. makersclaw-store and the
|
|
46
|
+
* template may copy this file as they copy app-actions.ts.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
/* Limits and names */
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
|
|
53
|
+
/** The numbers the plan calls defaults "to be tuned at build". */
|
|
54
|
+
export const AUTOMATION_LIMITS = {
|
|
55
|
+
/** Steps in one automation, nested ones included. */
|
|
56
|
+
steps: 60,
|
|
57
|
+
/** Nesting: a loop or a condition inside a loop or a condition. */
|
|
58
|
+
depth: 4,
|
|
59
|
+
/** Items one loop runs over. */
|
|
60
|
+
loopItems: 500,
|
|
61
|
+
/** A step's stored output, as JSON. Larger fails the step. */
|
|
62
|
+
outputBytes: 256 * 1024,
|
|
63
|
+
/** One expression's source. */
|
|
64
|
+
expressionChars: 1000,
|
|
65
|
+
/** Nodes in one expression's tree. */
|
|
66
|
+
expressionNodes: 200,
|
|
67
|
+
/** Nesting inside one expression. */
|
|
68
|
+
expressionDepth: 24,
|
|
69
|
+
/** Work one resolve may do before it is stopped. */
|
|
70
|
+
evalWork: 100_000,
|
|
71
|
+
/** A text an expression builds. */
|
|
72
|
+
stringChars: 262_144,
|
|
73
|
+
/** Nesting inside a step's value (`input`, `values`). */
|
|
74
|
+
valueDepth: 8,
|
|
75
|
+
/** Keys in one object of a step's value. */
|
|
76
|
+
valueKeys: 60,
|
|
77
|
+
/** A label, name or message. */
|
|
78
|
+
nameChars: 120,
|
|
79
|
+
messageChars: 500,
|
|
80
|
+
doesChars: 300,
|
|
81
|
+
/** Inputs a manual run asks for. */
|
|
82
|
+
inputs: 20,
|
|
83
|
+
/** Retry tries a step may ask for. */
|
|
84
|
+
retries: 5,
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/** A step's id, an input's name, a `set` key: lower case, stable (the
|
|
88
|
+
* merge key between a shipped version and a member's edit). */
|
|
89
|
+
export const AUTOMATION_ID = /^[a-z][a-z0-9_]{0,39}$/;
|
|
90
|
+
/** An automation's key: the shipped name an app's update merges by. */
|
|
91
|
+
export const AUTOMATION_KEY = /^[a-z][a-z0-9-]{0,59}$/;
|
|
92
|
+
|
|
93
|
+
export const AUTOMATION_FORMAT = 1;
|
|
94
|
+
|
|
95
|
+
/** Every kind a definition may name. Which of them an engine runs is
|
|
96
|
+
* `CODE_STEP_KINDS` and what comes after (the plan's phases 2 and 3). */
|
|
97
|
+
export const STEP_KINDS = [
|
|
98
|
+
"action",
|
|
99
|
+
"brain.read",
|
|
100
|
+
"condition",
|
|
101
|
+
"loop",
|
|
102
|
+
"set",
|
|
103
|
+
"wait",
|
|
104
|
+
"stop",
|
|
105
|
+
"notify",
|
|
106
|
+
"tool",
|
|
107
|
+
"search",
|
|
108
|
+
"llm",
|
|
109
|
+
"agent",
|
|
110
|
+
"zero",
|
|
111
|
+
"automation",
|
|
112
|
+
] as const;
|
|
113
|
+
export type StepKind = (typeof STEP_KINDS)[number];
|
|
114
|
+
|
|
115
|
+
/** The kinds an engine runs (phases 2 and 3): the code ones, which spend
|
|
116
|
+
* nothing (A3), and the four that call out and are metered: `tool`,
|
|
117
|
+
* `search`, `llm` and `agent` (docs/AUTOMATIONS-PLAN.md "Step kinds").
|
|
118
|
+
* `automation` (a call to another automation) is a later phase. The name
|
|
119
|
+
* is kept from phase 2: the doors and the template check read it. */
|
|
120
|
+
export const CODE_STEP_KINDS: readonly StepKind[] = ["action", "brain.read", "condition", "loop", "set", "wait", "stop", "notify", "tool", "search", "llm", "agent", "zero"];
|
|
121
|
+
|
|
122
|
+
/** The kinds that cost money to run: each is metered into the run's
|
|
123
|
+
* credits, counted against the app's allowance, held by the run's cap and
|
|
124
|
+
* priced before the run (`estimate`). */
|
|
125
|
+
export const METERED_STEP_KINDS: readonly StepKind[] = ["tool", "search", "llm", "agent", "zero"];
|
|
126
|
+
|
|
127
|
+
/** The most one Zero step may pay for a call, in USD: the platform's own
|
|
128
|
+
* per-call ceiling (agent-app/src/lib/zero.ts ZERO_MAX_PAY_CEILING_USD;
|
|
129
|
+
* keep the two equal). A workspace's own cap may be lower, never higher. */
|
|
130
|
+
export const ZERO_STEP_MAX_PAY_USD = 2;
|
|
131
|
+
/** The methods a Zero step may use (GET puts `input` on the url, the rest
|
|
132
|
+
* send it as a JSON body). */
|
|
133
|
+
export const ZERO_STEP_METHODS = ["GET", "POST", "PUT", "PATCH"] as const;
|
|
134
|
+
|
|
135
|
+
/** Kinds that do not exist, and why (A18): refused by name so a person or
|
|
136
|
+
* a model writing one is told the rule, not "unknown kind". */
|
|
137
|
+
export const REFUSED_STEP_KINDS: Readonly<Record<string, string>> = {
|
|
138
|
+
ask: "automations carry no approval step: nothing in a run waits for a yes (A18). When a yes is truly needed, the fixer asks the automation's owner",
|
|
139
|
+
approval: "automations carry no approval step: nothing in a run waits for a yes (A18). When a yes is truly needed, the fixer asks the automation's owner",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A trigger's type. Phase 2 runs the first two; phase 7 adds the rest:
|
|
143
|
+
* `event` (a topic an app fires), `action` (an app's declared action
|
|
144
|
+
* finished), `record` (the company brain changed), `webhook` (a secret URL
|
|
145
|
+
* of this automation's own) and `automation` (another one finished). */
|
|
146
|
+
export const TRIGGER_TYPES = ["schedule", "manual", "event", "action", "record", "webhook", "automation"] as const;
|
|
147
|
+
export type TriggerType = (typeof TRIGGER_TYPES)[number];
|
|
148
|
+
|
|
149
|
+
/** Triggers that do not exist yet, and why: refused by name so a person or
|
|
150
|
+
* a model writing one is told the rule, not "unknown type". */
|
|
151
|
+
export const REFUSED_TRIGGER_TYPES: Readonly<Record<string, string>> = {
|
|
152
|
+
connection:
|
|
153
|
+
"a connection's own events (a new mail, a new row in a sheet) are not offered yet: use a schedule with a tool step, or the webhook trigger that a connection's trigger can post to",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** An event topic: a short dotted slug ("rows.imported"). The same shape
|
|
157
|
+
* the apps' `_claw/events` door takes. */
|
|
158
|
+
export const EVENT_TOPIC = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
159
|
+
/** A company-brain kind's name, as a record trigger lists it. */
|
|
160
|
+
export const BRAIN_KIND_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
161
|
+
/** What a record trigger can hear. */
|
|
162
|
+
export const RECORD_OPS = ["create", "update"] as const;
|
|
163
|
+
/** How an `automation` trigger picks the finish it follows. */
|
|
164
|
+
export const FINISH_WHEN = ["done", "failed", "any"] as const;
|
|
165
|
+
/** Keyed runs an hour per automation unless `settings.max_per_hour` says. */
|
|
166
|
+
export const KEYED_RUNS_PER_HOUR = 20;
|
|
167
|
+
/** The longest chain of automations starting automations. */
|
|
168
|
+
export const TRIGGER_CHAIN_MAX = 5;
|
|
169
|
+
|
|
170
|
+
export const OVERLAP_MODES = ["skip", "queue", "allow"] as const;
|
|
171
|
+
export const FAILURE_MODES = ["stop", "retry_then_agent", "agent_first"] as const;
|
|
172
|
+
|
|
173
|
+
/* ------------------------------------------------------------------ */
|
|
174
|
+
/* The definition's types */
|
|
175
|
+
/* ------------------------------------------------------------------ */
|
|
176
|
+
|
|
177
|
+
/** A JSON value a step carries; strings may hold `{{ expressions }}`. */
|
|
178
|
+
export type StepValue = string | number | boolean | null | StepValue[] | { [key: string]: StepValue };
|
|
179
|
+
|
|
180
|
+
interface StepBase {
|
|
181
|
+
id: string;
|
|
182
|
+
label?: string;
|
|
183
|
+
/** `stop` (default): a step that failed for good fails the run.
|
|
184
|
+
* `continue`: the run goes on, the step's output is empty. */
|
|
185
|
+
on_error?: "stop" | "continue";
|
|
186
|
+
/** Retry tries after the first, for a transient failure (default 3). */
|
|
187
|
+
retry?: { max: number };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface ActionStep extends StepBase {
|
|
191
|
+
kind: "action";
|
|
192
|
+
/** The app's declared action (shared/src/app-actions.ts). */
|
|
193
|
+
action: string;
|
|
194
|
+
/** For a workspace automation: which app's. An app's own: absent. */
|
|
195
|
+
app?: string;
|
|
196
|
+
input?: { [key: string]: StepValue };
|
|
197
|
+
}
|
|
198
|
+
export interface BrainReadStep extends StepBase {
|
|
199
|
+
kind: "brain.read";
|
|
200
|
+
op: "search" | "get" | "list";
|
|
201
|
+
input: { [key: string]: StepValue };
|
|
202
|
+
}
|
|
203
|
+
export interface ConditionStep extends StepBase {
|
|
204
|
+
kind: "condition";
|
|
205
|
+
if: string;
|
|
206
|
+
then?: Step[];
|
|
207
|
+
else?: Step[];
|
|
208
|
+
}
|
|
209
|
+
export interface LoopStep extends StepBase {
|
|
210
|
+
kind: "loop";
|
|
211
|
+
over: string;
|
|
212
|
+
max?: number;
|
|
213
|
+
/** One at a time in v1. */
|
|
214
|
+
concurrency?: 1;
|
|
215
|
+
/** Evaluated after each pass; the values are the step's `collected`. */
|
|
216
|
+
collect?: string;
|
|
217
|
+
steps: Step[];
|
|
218
|
+
}
|
|
219
|
+
export interface SetStep extends StepBase {
|
|
220
|
+
kind: "set";
|
|
221
|
+
values: { [key: string]: StepValue };
|
|
222
|
+
}
|
|
223
|
+
export interface WaitStep extends StepBase {
|
|
224
|
+
kind: "wait";
|
|
225
|
+
/** A duration: `30s`, `10m`, `2h`, `3d`. */
|
|
226
|
+
for?: string;
|
|
227
|
+
/** An expression giving a time (ISO). */
|
|
228
|
+
until?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface StopStep extends StepBase {
|
|
231
|
+
kind: "stop";
|
|
232
|
+
message: string;
|
|
233
|
+
as?: "done" | "failed";
|
|
234
|
+
}
|
|
235
|
+
export interface NotifyStep extends StepBase {
|
|
236
|
+
kind: "notify";
|
|
237
|
+
message: string;
|
|
238
|
+
to?: "owner";
|
|
239
|
+
}
|
|
240
|
+
export interface ToolStep extends StepBase {
|
|
241
|
+
kind: "tool";
|
|
242
|
+
needs: { category: string };
|
|
243
|
+
tool: string;
|
|
244
|
+
input?: { [key: string]: StepValue };
|
|
245
|
+
account?: string;
|
|
246
|
+
}
|
|
247
|
+
export interface SearchStep extends StepBase {
|
|
248
|
+
kind: "search";
|
|
249
|
+
query: string;
|
|
250
|
+
limit?: number;
|
|
251
|
+
}
|
|
252
|
+
export interface LlmStep extends StepBase {
|
|
253
|
+
kind: "llm";
|
|
254
|
+
prompt: string;
|
|
255
|
+
system?: string;
|
|
256
|
+
tier?: "fast" | "smart";
|
|
257
|
+
schema?: Record<string, unknown>;
|
|
258
|
+
}
|
|
259
|
+
export interface AgentStep extends StepBase {
|
|
260
|
+
kind: "agent";
|
|
261
|
+
instructions: string;
|
|
262
|
+
cap_credits?: number;
|
|
263
|
+
schema?: Record<string, unknown>;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A paid capability from Zero's catalog (D31 widened: "zero … use the entire
|
|
267
|
+
* force of the platform"). Plain code, no model: one call to `url`, paid up
|
|
268
|
+
* to `max_pay_usd`, with the platform's own guards (the per-call, per-run and
|
|
269
|
+
* daily caps, the same call never paid for twice). `capability` and `url` are
|
|
270
|
+
* what the catalog named; `input` is the capability's own request (query
|
|
271
|
+
* params for GET, a JSON body otherwise).
|
|
272
|
+
*/
|
|
273
|
+
export interface ZeroStep extends StepBase {
|
|
274
|
+
kind: "zero";
|
|
275
|
+
/** The catalog's name for it: a z_xxx.N token or a slug. */
|
|
276
|
+
capability: string;
|
|
277
|
+
/** The capability's address (https). */
|
|
278
|
+
url: string;
|
|
279
|
+
method?: (typeof ZERO_STEP_METHODS)[number];
|
|
280
|
+
input?: { [key: string]: StepValue };
|
|
281
|
+
/** The most one call may pay, in USD (at most `ZERO_STEP_MAX_PAY_USD`). */
|
|
282
|
+
max_pay_usd: number;
|
|
283
|
+
}
|
|
284
|
+
export interface AutomationStep extends StepBase {
|
|
285
|
+
kind: "automation";
|
|
286
|
+
key: string;
|
|
287
|
+
input?: { [key: string]: StepValue };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type Step =
|
|
291
|
+
| ActionStep
|
|
292
|
+
| BrainReadStep
|
|
293
|
+
| ConditionStep
|
|
294
|
+
| LoopStep
|
|
295
|
+
| SetStep
|
|
296
|
+
| WaitStep
|
|
297
|
+
| StopStep
|
|
298
|
+
| NotifyStep
|
|
299
|
+
| ToolStep
|
|
300
|
+
| SearchStep
|
|
301
|
+
| LlmStep
|
|
302
|
+
| AgentStep
|
|
303
|
+
| ZeroStep
|
|
304
|
+
| AutomationStep;
|
|
305
|
+
|
|
306
|
+
export type Trigger =
|
|
307
|
+
| { type: "schedule"; cron: string; tz: string }
|
|
308
|
+
| { type: "manual" }
|
|
309
|
+
/** An app fired `topic` (`_claw/events`), or the workspace did. `app`
|
|
310
|
+
* names the app a workspace automation hears; an app's own hears its own. */
|
|
311
|
+
| { type: "event"; topic: string; app?: string }
|
|
312
|
+
/** The app's declared action finished. Never an action an automation ran
|
|
313
|
+
* (a loop-proof rule): that is what `automation` is for. */
|
|
314
|
+
| { type: "action"; action: string; app?: string }
|
|
315
|
+
/** The company brain changed: records of `kinds` (default: every kind the
|
|
316
|
+
* owner may read) were created or updated, by a person or the app's
|
|
317
|
+
* agent, never by one of the app's own jobs. */
|
|
318
|
+
| { type: "record"; kinds?: string[]; ops?: ("create" | "update")[]; app?: string }
|
|
319
|
+
/** A POST to this automation's secret URL. */
|
|
320
|
+
| { type: "webhook" }
|
|
321
|
+
/** The automation `key` (of the same owner) finished. */
|
|
322
|
+
| { type: "automation"; key: string; when: "done" | "failed" | "any" };
|
|
323
|
+
|
|
324
|
+
export interface InputSpec {
|
|
325
|
+
type: "string" | "number" | "boolean" | "date";
|
|
326
|
+
label?: string;
|
|
327
|
+
required?: boolean;
|
|
328
|
+
default?: string | number | boolean;
|
|
329
|
+
options?: string[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface AutomationSettings {
|
|
333
|
+
overlap: "skip" | "queue" | "allow";
|
|
334
|
+
on_failure: "stop" | "retry_then_agent" | "agent_first";
|
|
335
|
+
only_if_changed: boolean;
|
|
336
|
+
run_cap_credits?: number;
|
|
337
|
+
/** Keyed runs an hour (default `KEYED_RUNS_PER_HOUR`): a storm of
|
|
338
|
+
* distinct events cannot eat a week's budget in a minute. */
|
|
339
|
+
max_per_hour?: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface AutomationDef {
|
|
343
|
+
format: 1;
|
|
344
|
+
key: string;
|
|
345
|
+
name: string;
|
|
346
|
+
does?: string;
|
|
347
|
+
trigger: Trigger;
|
|
348
|
+
inputs: Record<string, InputSpec>;
|
|
349
|
+
settings: AutomationSettings;
|
|
350
|
+
steps: Step[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface Problem {
|
|
354
|
+
/** Where: "steps[2].input.since", "trigger.cron". */
|
|
355
|
+
path: string;
|
|
356
|
+
message: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ------------------------------------------------------------------ */
|
|
360
|
+
/* Small helpers */
|
|
361
|
+
/* ------------------------------------------------------------------ */
|
|
362
|
+
|
|
363
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
364
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
|
|
368
|
+
|
|
369
|
+
/** Names no path may read or write: the way up to Object.prototype. */
|
|
370
|
+
const BAD_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
371
|
+
|
|
372
|
+
/** JSON with sorted keys: the same document always gives the same text,
|
|
373
|
+
* so a hash of it says "did the member change it". */
|
|
374
|
+
export function canonicalJson(value: unknown): string {
|
|
375
|
+
const seen = (v: unknown): unknown => {
|
|
376
|
+
if (Array.isArray(v)) return v.map(seen);
|
|
377
|
+
if (isObject(v)) {
|
|
378
|
+
const out: Record<string, unknown> = {};
|
|
379
|
+
for (const k of Object.keys(v).sort()) if (v[k] !== undefined) out[k] = seen(v[k]);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
return v;
|
|
383
|
+
};
|
|
384
|
+
return JSON.stringify(seen(value));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const DURATION = /^(\d{1,6})(s|m|h|d)$/;
|
|
388
|
+
const UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
389
|
+
/** The longest wait: thirty days. */
|
|
390
|
+
export const WAIT_MAX_MS = 30 * 86_400_000;
|
|
391
|
+
|
|
392
|
+
/** `10m` → milliseconds; null when it is not a duration. */
|
|
393
|
+
export function durationMs(text: unknown): number | null {
|
|
394
|
+
if (typeof text !== "string") return null;
|
|
395
|
+
const m = DURATION.exec(text);
|
|
396
|
+
if (!m) return null;
|
|
397
|
+
return Number(m[1]) * UNIT_MS[m[2]!]!;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ------------------------------------------------------------------ */
|
|
401
|
+
/* The expression language: tokens */
|
|
402
|
+
/* ------------------------------------------------------------------ */
|
|
403
|
+
|
|
404
|
+
/** A mistake in an expression, in words, with where it is. */
|
|
405
|
+
export class ExprError extends Error {
|
|
406
|
+
readonly at: number;
|
|
407
|
+
constructor(message: string, at = 0) {
|
|
408
|
+
super(message);
|
|
409
|
+
this.name = "ExprError";
|
|
410
|
+
this.at = at;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A reference with no value, where the step needed one (strict). */
|
|
415
|
+
export class MappingError extends Error {
|
|
416
|
+
readonly source: string;
|
|
417
|
+
constructor(source: string) {
|
|
418
|
+
super(`${source} has no value`);
|
|
419
|
+
this.name = "MappingError";
|
|
420
|
+
this.source = source;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
type Tok =
|
|
425
|
+
| { t: "num"; v: number; at: number }
|
|
426
|
+
| { t: "str"; v: string; at: number }
|
|
427
|
+
| { t: "id"; v: string; at: number }
|
|
428
|
+
| { t: "op"; v: string; at: number }
|
|
429
|
+
| { t: "end"; at: number };
|
|
430
|
+
|
|
431
|
+
const OPS3 = new Set<string>([]);
|
|
432
|
+
const OPS2 = new Set(["&&", "||", "??", "==", "!=", "<=", ">="]);
|
|
433
|
+
const OPS1 = new Set([".", "[", "]", "(", ")", ",", "?", ":", "!", "<", ">", "+", "-", "*", "/", "%"]);
|
|
434
|
+
|
|
435
|
+
function tokenize(src: string): Tok[] {
|
|
436
|
+
if (src.length > AUTOMATION_LIMITS.expressionChars) {
|
|
437
|
+
throw new ExprError(`an expression is at most ${AUTOMATION_LIMITS.expressionChars} characters`);
|
|
438
|
+
}
|
|
439
|
+
const out: Tok[] = [];
|
|
440
|
+
let i = 0;
|
|
441
|
+
while (i < src.length) {
|
|
442
|
+
const c = src[i]!;
|
|
443
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
444
|
+
i++;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (c >= "0" && c <= "9") {
|
|
448
|
+
let j = i;
|
|
449
|
+
while (j < src.length && src[j]! >= "0" && src[j]! <= "9") j++;
|
|
450
|
+
if (src[j] === "." && src[j + 1]! >= "0" && src[j + 1]! <= "9") {
|
|
451
|
+
j++;
|
|
452
|
+
while (j < src.length && src[j]! >= "0" && src[j]! <= "9") j++;
|
|
453
|
+
}
|
|
454
|
+
if (j - i > 15) throw new ExprError("that number is too long", i);
|
|
455
|
+
out.push({ t: "num", v: Number(src.slice(i, j)), at: i });
|
|
456
|
+
i = j;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (c === '"' || c === "'") {
|
|
460
|
+
let j = i + 1;
|
|
461
|
+
let s = "";
|
|
462
|
+
for (;;) {
|
|
463
|
+
if (j >= src.length) throw new ExprError("a quote is not closed", i);
|
|
464
|
+
const d = src[j]!;
|
|
465
|
+
if (d === c) break;
|
|
466
|
+
if (d === "\\") {
|
|
467
|
+
const e = src[j + 1];
|
|
468
|
+
if (e === undefined) throw new ExprError("a quote is not closed", i);
|
|
469
|
+
s += e === "n" ? "\n" : e === "t" ? "\t" : e;
|
|
470
|
+
j += 2;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
s += d;
|
|
474
|
+
j++;
|
|
475
|
+
}
|
|
476
|
+
out.push({ t: "str", v: s, at: i });
|
|
477
|
+
i = j + 1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_") {
|
|
481
|
+
let j = i + 1;
|
|
482
|
+
while (j < src.length && /[A-Za-z0-9_]/.test(src[j]!)) j++;
|
|
483
|
+
out.push({ t: "id", v: src.slice(i, j), at: i });
|
|
484
|
+
i = j;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const three = src.slice(i, i + 3);
|
|
488
|
+
if (OPS3.has(three)) {
|
|
489
|
+
out.push({ t: "op", v: three, at: i });
|
|
490
|
+
i += 3;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const two = src.slice(i, i + 2);
|
|
494
|
+
if (OPS2.has(two)) {
|
|
495
|
+
out.push({ t: "op", v: two, at: i });
|
|
496
|
+
i += 2;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (OPS1.has(c)) {
|
|
500
|
+
out.push({ t: "op", v: c, at: i });
|
|
501
|
+
i++;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
throw new ExprError(`"${c}" is not part of the expression language`, i);
|
|
505
|
+
}
|
|
506
|
+
out.push({ t: "end", at: src.length });
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ------------------------------------------------------------------ */
|
|
511
|
+
/* The expression language: the tree and the parser */
|
|
512
|
+
/* ------------------------------------------------------------------ */
|
|
513
|
+
|
|
514
|
+
/** The names an expression may start from. */
|
|
515
|
+
export const EXPR_ROOTS = ["trigger", "steps", "item", "index", "vars", "run", "now", "inputs"] as const;
|
|
516
|
+
type Root = (typeof EXPR_ROOTS)[number];
|
|
517
|
+
|
|
518
|
+
export type Expr =
|
|
519
|
+
| { n: "lit"; v: string | number | boolean | null }
|
|
520
|
+
| { n: "path"; root: Root; segs: PathSeg[] }
|
|
521
|
+
| { n: "un"; op: "!" | "-"; a: Expr }
|
|
522
|
+
| { n: "bin"; op: string; a: Expr; b: Expr }
|
|
523
|
+
| { n: "tern"; c: Expr; a: Expr; b: Expr }
|
|
524
|
+
| { n: "call"; fn: string; args: Expr[] }
|
|
525
|
+
/** A call's result read further: `list.first(x).id`, `list.filter(...)[0]`. */
|
|
526
|
+
| { n: "get"; base: Expr; segs: PathSeg[] };
|
|
527
|
+
|
|
528
|
+
/** A path segment: a name, or an expression in brackets. */
|
|
529
|
+
export type PathSeg = { k: "name"; v: string } | { k: "idx"; e: Expr };
|
|
530
|
+
|
|
531
|
+
interface FunctionDef {
|
|
532
|
+
/** Fewest and most arguments. */
|
|
533
|
+
min: number;
|
|
534
|
+
max: number;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The whole library (A8): pure, bounded, no loops, no code. */
|
|
538
|
+
export const EXPR_FUNCTIONS: Readonly<Record<string, FunctionDef>> = {
|
|
539
|
+
"date.add": { min: 3, max: 3 },
|
|
540
|
+
"date.diff": { min: 3, max: 3 },
|
|
541
|
+
"text.trim": { min: 1, max: 1 },
|
|
542
|
+
"text.lower": { min: 1, max: 1 },
|
|
543
|
+
"text.upper": { min: 1, max: 1 },
|
|
544
|
+
"text.join": { min: 1, max: 2 },
|
|
545
|
+
"text.contains": { min: 2, max: 2 },
|
|
546
|
+
"num.round": { min: 1, max: 2 },
|
|
547
|
+
"num.min": { min: 2, max: 2 },
|
|
548
|
+
"num.max": { min: 2, max: 2 },
|
|
549
|
+
"list.filter": { min: 3, max: 4 },
|
|
550
|
+
"list.map": { min: 2, max: 2 },
|
|
551
|
+
"list.count": { min: 1, max: 1 },
|
|
552
|
+
"list.first": { min: 1, max: 1 },
|
|
553
|
+
"list.slice": { min: 2, max: 2 },
|
|
554
|
+
"list.sum": { min: 1, max: 2 },
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
class Parser {
|
|
558
|
+
private i = 0;
|
|
559
|
+
private nodes = 0;
|
|
560
|
+
private depth = 0;
|
|
561
|
+
private readonly toks: Tok[];
|
|
562
|
+
constructor(toks: Tok[]) {
|
|
563
|
+
this.toks = toks;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
parse(): Expr {
|
|
567
|
+
const e = this.ternary();
|
|
568
|
+
const t = this.peek();
|
|
569
|
+
if (t.t !== "end") throw new ExprError(`unexpected "${this.show(t)}"`, t.at);
|
|
570
|
+
return e;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private show(t: Tok): string {
|
|
574
|
+
return t.t === "end" ? "the end" : t.t === "num" || t.t === "str" ? String(t.v) : t.v;
|
|
575
|
+
}
|
|
576
|
+
private peek(): Tok {
|
|
577
|
+
return this.toks[this.i]!;
|
|
578
|
+
}
|
|
579
|
+
private take(): Tok {
|
|
580
|
+
return this.toks[this.i++]!;
|
|
581
|
+
}
|
|
582
|
+
private isOp(v: string): boolean {
|
|
583
|
+
const t = this.peek();
|
|
584
|
+
return t.t === "op" && t.v === v;
|
|
585
|
+
}
|
|
586
|
+
private eat(v: string): boolean {
|
|
587
|
+
if (!this.isOp(v)) return false;
|
|
588
|
+
this.i++;
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
private expect(v: string, why: string): void {
|
|
592
|
+
if (!this.eat(v)) throw new ExprError(`expected "${v}" ${why}`, this.peek().at);
|
|
593
|
+
}
|
|
594
|
+
private node<T extends Expr>(n: T): T {
|
|
595
|
+
if (++this.nodes > AUTOMATION_LIMITS.expressionNodes) throw new ExprError("that expression is too large");
|
|
596
|
+
return n;
|
|
597
|
+
}
|
|
598
|
+
private enter(): void {
|
|
599
|
+
if (++this.depth > AUTOMATION_LIMITS.expressionDepth) throw new ExprError("that expression nests too deep");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private ternary(): Expr {
|
|
603
|
+
this.enter();
|
|
604
|
+
const c = this.binary(0);
|
|
605
|
+
if (this.eat("?")) {
|
|
606
|
+
const a = this.ternary();
|
|
607
|
+
this.expect(":", "in a ? : choice");
|
|
608
|
+
const b = this.ternary();
|
|
609
|
+
this.depth--;
|
|
610
|
+
return this.node({ n: "tern", c, a, b });
|
|
611
|
+
}
|
|
612
|
+
this.depth--;
|
|
613
|
+
return c;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Lowest to highest. `??` and `||` bind as in JavaScript's habit. */
|
|
617
|
+
private static LEVELS: string[][] = [["??"], ["||"], ["&&"], ["==", "!="], ["<", "<=", ">", ">="], ["+", "-"], ["*", "/", "%"]];
|
|
618
|
+
|
|
619
|
+
private binary(level: number): Expr {
|
|
620
|
+
if (level >= Parser.LEVELS.length) return this.unary();
|
|
621
|
+
let left = this.binary(level + 1);
|
|
622
|
+
for (;;) {
|
|
623
|
+
const t = this.peek();
|
|
624
|
+
if (t.t === "op" && Parser.LEVELS[level]!.includes(t.v)) {
|
|
625
|
+
this.i++;
|
|
626
|
+
const right = this.binary(level + 1);
|
|
627
|
+
left = this.node({ n: "bin", op: t.v, a: left, b: right });
|
|
628
|
+
} else return left;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private unary(): Expr {
|
|
633
|
+
if (this.isOp("!") || this.isOp("-")) {
|
|
634
|
+
const op = (this.take() as { v: "!" | "-" }).v;
|
|
635
|
+
this.enter();
|
|
636
|
+
const a = this.unary();
|
|
637
|
+
this.depth--;
|
|
638
|
+
return this.node({ n: "un", op, a });
|
|
639
|
+
}
|
|
640
|
+
return this.primary();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private primary(): Expr {
|
|
644
|
+
const t = this.take();
|
|
645
|
+
if (t.t === "num") return this.node({ n: "lit", v: t.v });
|
|
646
|
+
if (t.t === "str") return this.node({ n: "lit", v: t.v });
|
|
647
|
+
if (t.t === "op" && t.v === "(") {
|
|
648
|
+
this.enter();
|
|
649
|
+
const e = this.ternary();
|
|
650
|
+
this.depth--;
|
|
651
|
+
this.expect(")", "to close the bracket");
|
|
652
|
+
return e;
|
|
653
|
+
}
|
|
654
|
+
if (t.t !== "id") throw new ExprError(`unexpected "${this.show(t)}"`, t.at);
|
|
655
|
+
if (t.v === "true") return this.node({ n: "lit", v: true });
|
|
656
|
+
if (t.v === "false") return this.node({ n: "lit", v: false });
|
|
657
|
+
if (t.v === "null") return this.node({ n: "lit", v: null });
|
|
658
|
+
// A function: a dotted name from the library, then arguments.
|
|
659
|
+
const name = this.dotted(t.v);
|
|
660
|
+
if (this.isOp("(")) {
|
|
661
|
+
const def = EXPR_FUNCTIONS[name];
|
|
662
|
+
if (!def) throw new ExprError(`there is no function "${name}"`, t.at);
|
|
663
|
+
this.i++;
|
|
664
|
+
const args: Expr[] = [];
|
|
665
|
+
if (!this.isOp(")")) {
|
|
666
|
+
for (;;) {
|
|
667
|
+
args.push(this.ternary());
|
|
668
|
+
if (this.eat(",")) continue;
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
this.expect(")", `to close ${name}(`);
|
|
673
|
+
if (args.length < def.min || args.length > def.max) {
|
|
674
|
+
throw new ExprError(`${name} takes ${def.min === def.max ? def.min : `${def.min} to ${def.max}`} arguments, not ${args.length}`, t.at);
|
|
675
|
+
}
|
|
676
|
+
const callNode = this.node({ n: "call", fn: name, args }) as Expr;
|
|
677
|
+
const segs = this.segments(t.at);
|
|
678
|
+
return segs.length ? this.node({ n: "get", base: callNode, segs }) : callNode;
|
|
679
|
+
}
|
|
680
|
+
return this.path(t);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** `.name` and `[expr]` after a value. */
|
|
684
|
+
private segments(at: number): PathSeg[] {
|
|
685
|
+
const segs: PathSeg[] = [];
|
|
686
|
+
for (;;) {
|
|
687
|
+
if (this.eat(".")) {
|
|
688
|
+
const t = this.take();
|
|
689
|
+
if (t.t !== "id") throw new ExprError('expected a name after "."', t.at);
|
|
690
|
+
if (BAD_KEYS.has(t.v)) throw new ExprError(`"${t.v}" cannot be read`, t.at);
|
|
691
|
+
segs.push({ k: "name", v: t.v });
|
|
692
|
+
} else if (this.eat("[")) {
|
|
693
|
+
this.enter();
|
|
694
|
+
const e = this.ternary();
|
|
695
|
+
this.depth--;
|
|
696
|
+
this.expect("]", "to close the bracket");
|
|
697
|
+
if (e.n === "lit" && typeof e.v === "string") {
|
|
698
|
+
if (BAD_KEYS.has(e.v)) throw new ExprError(`"${e.v}" cannot be read`, at);
|
|
699
|
+
segs.push({ k: "name", v: e.v });
|
|
700
|
+
} else segs.push({ k: "idx", e });
|
|
701
|
+
} else return segs;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** `date.add` read as a name, without consuming a path. */
|
|
706
|
+
private dotted(first: string): string {
|
|
707
|
+
let name = first;
|
|
708
|
+
let j = this.i;
|
|
709
|
+
while (this.toks[j]?.t === "op" && (this.toks[j] as { v: string }).v === "." && this.toks[j + 1]?.t === "id") {
|
|
710
|
+
name += "." + (this.toks[j + 1] as { v: string }).v;
|
|
711
|
+
j += 2;
|
|
712
|
+
}
|
|
713
|
+
// Only a call consumes the dots; a path re-reads them.
|
|
714
|
+
if (this.toks[j]?.t === "op" && (this.toks[j] as { v: string }).v === "(" && name.includes(".")) {
|
|
715
|
+
this.i = j;
|
|
716
|
+
return name;
|
|
717
|
+
}
|
|
718
|
+
return first;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private path(first: Tok & { t: "id" }): Expr {
|
|
722
|
+
if (!(EXPR_ROOTS as readonly string[]).includes(first.v)) {
|
|
723
|
+
throw new ExprError(`"${first.v}" is not something an expression can read (use ${EXPR_ROOTS.join(", ")})`, first.at);
|
|
724
|
+
}
|
|
725
|
+
const segs = this.segments(first.at);
|
|
726
|
+
return this.node({ n: "path", root: first.v as Root, segs });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Parse one expression (no braces). Throws ExprError. */
|
|
731
|
+
export function parseExpression(src: string): Expr {
|
|
732
|
+
return new Parser(tokenize(src)).parse();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/* ------------------------------------------------------------------ */
|
|
736
|
+
/* Templates: text with {{ expressions }} */
|
|
737
|
+
/* ------------------------------------------------------------------ */
|
|
738
|
+
|
|
739
|
+
export type TemplatePart = string | Expr;
|
|
740
|
+
export interface Template {
|
|
741
|
+
parts: TemplatePart[];
|
|
742
|
+
/** The text was exactly one expression: its value keeps its type. */
|
|
743
|
+
single: Expr | null;
|
|
744
|
+
/** The expression sources, for messages. */
|
|
745
|
+
sources: string[];
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Split text at `{{ ... }}`, string-aware so `"}}"` inside quotes holds. */
|
|
749
|
+
export function parseTemplate(text: string): Template {
|
|
750
|
+
const parts: TemplatePart[] = [];
|
|
751
|
+
const sources: string[] = [];
|
|
752
|
+
let i = 0;
|
|
753
|
+
let lit = "";
|
|
754
|
+
while (i < text.length) {
|
|
755
|
+
const open = text.indexOf("{{", i);
|
|
756
|
+
if (open < 0) {
|
|
757
|
+
lit += text.slice(i);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
lit += text.slice(i, open);
|
|
761
|
+
let j = open + 2;
|
|
762
|
+
let quote: string | null = null;
|
|
763
|
+
let close = -1;
|
|
764
|
+
while (j < text.length) {
|
|
765
|
+
const c = text[j]!;
|
|
766
|
+
if (quote) {
|
|
767
|
+
if (c === "\\") j++;
|
|
768
|
+
else if (c === quote) quote = null;
|
|
769
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
770
|
+
else if (c === "}" && text[j + 1] === "}") {
|
|
771
|
+
close = j;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
j++;
|
|
775
|
+
}
|
|
776
|
+
if (close < 0) throw new ExprError('"{{" is never closed with "}}"', open);
|
|
777
|
+
const src = text.slice(open + 2, close).trim();
|
|
778
|
+
if (!src) throw new ExprError("an empty {{ }}", open);
|
|
779
|
+
if (lit) parts.push(lit);
|
|
780
|
+
lit = "";
|
|
781
|
+
try {
|
|
782
|
+
parts.push(parseExpression(src));
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (error instanceof ExprError) throw new ExprError(`{{ ${src.slice(0, 60)} }}: ${error.message}`, open + 2 + error.at);
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
sources.push(src);
|
|
788
|
+
i = close + 2;
|
|
789
|
+
}
|
|
790
|
+
if (lit) parts.push(lit);
|
|
791
|
+
const only = parts.length === 1 && typeof parts[0] !== "string" ? (parts[0] as Expr) : null;
|
|
792
|
+
return { parts, single: only, sources };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** A template that must be exactly one expression: `if`, `over`. A bare
|
|
796
|
+
* expression without braces is accepted and means the same. */
|
|
797
|
+
export function parseSingle(text: string): Expr {
|
|
798
|
+
const t = text.trim();
|
|
799
|
+
if (!t.includes("{{")) return parseExpression(t);
|
|
800
|
+
const tpl = parseTemplate(t);
|
|
801
|
+
if (!tpl.single) throw new ExprError("this must be one {{ expression }} and nothing else");
|
|
802
|
+
return tpl.single;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/* ------------------------------------------------------------------ */
|
|
806
|
+
/* The expression language: evaluation */
|
|
807
|
+
/* ------------------------------------------------------------------ */
|
|
808
|
+
|
|
809
|
+
/** What an expression reads. A step's data lives under `steps.<id>.output`. */
|
|
810
|
+
export interface Scope {
|
|
811
|
+
trigger?: unknown;
|
|
812
|
+
steps?: Record<string, { output?: unknown } | undefined>;
|
|
813
|
+
item?: unknown;
|
|
814
|
+
index?: number;
|
|
815
|
+
vars?: unknown;
|
|
816
|
+
run?: unknown;
|
|
817
|
+
/** An ISO time: when this step began (stable across a replay). */
|
|
818
|
+
now?: string;
|
|
819
|
+
inputs?: unknown;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** The work an evaluation may still do (AUTOMATION_LIMITS.evalWork). */
|
|
823
|
+
export interface Meter {
|
|
824
|
+
left: number;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function spend(m: Meter, n = 1): void {
|
|
828
|
+
m.left -= n;
|
|
829
|
+
if (m.left < 0) throw new ExprError("that does too much work; make the list or the expression smaller");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function truthy(v: unknown): boolean {
|
|
833
|
+
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
|
|
834
|
+
if (typeof v === "number" && Number.isNaN(v)) return false;
|
|
835
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
836
|
+
if (isObject(v)) return Object.keys(v).length > 0;
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Text for an interpolation: no "undefined", no "[object Object]". */
|
|
841
|
+
export function toText(v: unknown): string {
|
|
842
|
+
if (v === null || v === undefined) return "";
|
|
843
|
+
if (typeof v === "string") return v;
|
|
844
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
845
|
+
const s = JSON.stringify(v);
|
|
846
|
+
return s === undefined ? "" : s;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function capText(s: string): string {
|
|
850
|
+
if (s.length > AUTOMATION_LIMITS.stringChars) throw new ExprError("that text is too long");
|
|
851
|
+
return s;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function getProp(obj: unknown, key: string | number): unknown {
|
|
855
|
+
if (obj === null || obj === undefined) return undefined;
|
|
856
|
+
if (typeof key === "number") {
|
|
857
|
+
return Array.isArray(obj) && Number.isInteger(key) && key >= 0 ? obj[key] : undefined;
|
|
858
|
+
}
|
|
859
|
+
if (typeof obj === "string") return key === "length" ? obj.length : undefined;
|
|
860
|
+
if (Array.isArray(obj)) return key === "length" ? obj.length : undefined;
|
|
861
|
+
if (typeof obj === "object") {
|
|
862
|
+
if (BAD_KEYS.has(key)) return undefined;
|
|
863
|
+
return hasOwn(obj, key) ? (obj as Record<string, unknown>)[key] : undefined;
|
|
864
|
+
}
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function deepEqual(a: unknown, b: unknown, m: Meter, depth = 0): boolean {
|
|
869
|
+
spend(m);
|
|
870
|
+
if (depth > 64) throw new ExprError("that data nests too deep to compare");
|
|
871
|
+
if (a === undefined) a = null;
|
|
872
|
+
if (b === undefined) b = null;
|
|
873
|
+
if (a === b) return true;
|
|
874
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
|
|
875
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
876
|
+
const ka = Object.keys(a as object);
|
|
877
|
+
const kb = Object.keys(b as object);
|
|
878
|
+
if (ka.length !== kb.length) return false;
|
|
879
|
+
for (const k of ka) {
|
|
880
|
+
if (!hasOwn(b as object, k) || !deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], m, depth + 1)) return false;
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function num(v: unknown, what: string): number {
|
|
886
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new ExprError(`${what} needs a number`);
|
|
887
|
+
return v;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function isoOf(v: unknown, what: string): number {
|
|
891
|
+
if (typeof v !== "string") throw new ExprError(`${what} needs a date`);
|
|
892
|
+
const t = Date.parse(v);
|
|
893
|
+
if (!Number.isFinite(t)) throw new ExprError(`${what}: "${v.slice(0, 40)}" is not a date`);
|
|
894
|
+
return t;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const DATE_UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
898
|
+
|
|
899
|
+
function unitMs(v: unknown): number {
|
|
900
|
+
const u = typeof v === "string" ? DATE_UNITS[v] : undefined;
|
|
901
|
+
if (!u) throw new ExprError('a unit is "s", "m", "h", "d" or "w"');
|
|
902
|
+
return u;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function cmp(op: string, a: unknown, b: unknown): boolean {
|
|
906
|
+
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
907
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
908
|
+
return op === "<" ? a < b : op === "<=" ? a <= b : op === ">" ? a > b : a >= b;
|
|
909
|
+
}
|
|
910
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
911
|
+
return op === "<" ? a < b : op === "<=" ? a <= b : op === ">" ? a > b : a >= b;
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function listOf(v: unknown, what: string): unknown[] {
|
|
917
|
+
if (v === null || v === undefined) return [];
|
|
918
|
+
if (!Array.isArray(v)) throw new ExprError(`${what} needs a list`);
|
|
919
|
+
return v;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function call(fn: string, a: unknown[], m: Meter): unknown {
|
|
923
|
+
switch (fn) {
|
|
924
|
+
case "date.add": {
|
|
925
|
+
const t = isoOf(a[0], "date.add") + num(a[1], "date.add") * unitMs(a[2]);
|
|
926
|
+
if (!Number.isFinite(t) || Math.abs(t) > 8.64e15) throw new ExprError("date.add: that date is out of range");
|
|
927
|
+
return new Date(t).toISOString();
|
|
928
|
+
}
|
|
929
|
+
case "date.diff":
|
|
930
|
+
return (isoOf(a[0], "date.diff") - isoOf(a[1], "date.diff")) / unitMs(a[2]);
|
|
931
|
+
case "text.trim":
|
|
932
|
+
return toText(a[0]).trim();
|
|
933
|
+
case "text.lower":
|
|
934
|
+
return toText(a[0]).toLowerCase();
|
|
935
|
+
case "text.upper":
|
|
936
|
+
return toText(a[0]).toUpperCase();
|
|
937
|
+
case "text.join": {
|
|
938
|
+
const list = listOf(a[0], "text.join");
|
|
939
|
+
spend(m, list.length);
|
|
940
|
+
return capText(list.map(toText).join(a[1] === undefined ? ", " : toText(a[1])));
|
|
941
|
+
}
|
|
942
|
+
case "text.contains":
|
|
943
|
+
return toText(a[0]).includes(toText(a[1]));
|
|
944
|
+
case "num.round": {
|
|
945
|
+
const digits = a[1] === undefined ? 0 : Math.max(0, Math.min(10, Math.trunc(num(a[1], "num.round"))));
|
|
946
|
+
const f = 10 ** digits;
|
|
947
|
+
return Math.round(num(a[0], "num.round") * f) / f;
|
|
948
|
+
}
|
|
949
|
+
case "num.min":
|
|
950
|
+
return Math.min(num(a[0], "num.min"), num(a[1], "num.min"));
|
|
951
|
+
case "num.max":
|
|
952
|
+
return Math.max(num(a[0], "num.max"), num(a[1], "num.max"));
|
|
953
|
+
case "list.filter": {
|
|
954
|
+
const list = listOf(a[0], "list.filter");
|
|
955
|
+
const field = toText(a[1]);
|
|
956
|
+
const hasOp = a.length === 4;
|
|
957
|
+
const op = hasOp ? toText(a[2]) : "==";
|
|
958
|
+
const want = hasOp ? a[3] : a[2];
|
|
959
|
+
if (!["==", "!=", "<", "<=", ">", ">=", "contains"].includes(op)) throw new ExprError(`list.filter: "${op}" is not a comparison`);
|
|
960
|
+
spend(m, list.length);
|
|
961
|
+
return list.filter((x) => {
|
|
962
|
+
const v = field === "" ? x : getProp(x, field);
|
|
963
|
+
if (op === "==") return deepEqual(v, want, m);
|
|
964
|
+
if (op === "!=") return !deepEqual(v, want, m);
|
|
965
|
+
if (op === "contains") return Array.isArray(v) ? v.some((y) => deepEqual(y, want, m)) : toText(v).includes(toText(want));
|
|
966
|
+
return cmp(op, v, want);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
case "list.map": {
|
|
970
|
+
const list = listOf(a[0], "list.map");
|
|
971
|
+
const field = toText(a[1]);
|
|
972
|
+
spend(m, list.length);
|
|
973
|
+
return list.map((x) => (field === "" ? x : getProp(x, field)));
|
|
974
|
+
}
|
|
975
|
+
case "list.count":
|
|
976
|
+
return a[0] === null || a[0] === undefined ? 0 : listOf(a[0], "list.count").length;
|
|
977
|
+
case "list.first":
|
|
978
|
+
return listOf(a[0], "list.first")[0];
|
|
979
|
+
case "list.slice":
|
|
980
|
+
return listOf(a[0], "list.slice").slice(0, Math.max(0, Math.trunc(num(a[1], "list.slice"))));
|
|
981
|
+
case "list.sum": {
|
|
982
|
+
const list = listOf(a[0], "list.sum");
|
|
983
|
+
const field = a[1] === undefined ? "" : toText(a[1]);
|
|
984
|
+
spend(m, list.length);
|
|
985
|
+
let total = 0;
|
|
986
|
+
for (const x of list) {
|
|
987
|
+
const v = field === "" ? x : getProp(x, field);
|
|
988
|
+
if (typeof v === "number" && Number.isFinite(v)) total += v;
|
|
989
|
+
}
|
|
990
|
+
return total;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
throw new ExprError(`there is no function "${fn}"`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function evalNode(e: Expr, scope: Scope, m: Meter): unknown {
|
|
997
|
+
spend(m);
|
|
998
|
+
switch (e.n) {
|
|
999
|
+
case "lit":
|
|
1000
|
+
return e.v;
|
|
1001
|
+
case "path": {
|
|
1002
|
+
let v: unknown = e.root === "now" ? scope.now : (scope as Record<string, unknown>)[e.root];
|
|
1003
|
+
for (const seg of e.segs) {
|
|
1004
|
+
if (v === null || v === undefined) return undefined;
|
|
1005
|
+
if (seg.k === "name") v = getProp(v, seg.v);
|
|
1006
|
+
else {
|
|
1007
|
+
const key = evalNode(seg.e, scope, m);
|
|
1008
|
+
if (typeof key === "string") v = BAD_KEYS.has(key) ? undefined : getProp(v, key);
|
|
1009
|
+
else if (typeof key === "number") v = getProp(v, key);
|
|
1010
|
+
else v = undefined;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return v;
|
|
1014
|
+
}
|
|
1015
|
+
case "un": {
|
|
1016
|
+
const a = evalNode(e.a, scope, m);
|
|
1017
|
+
return e.op === "!" ? !truthy(a) : -num(a, "a minus sign");
|
|
1018
|
+
}
|
|
1019
|
+
case "tern":
|
|
1020
|
+
return truthy(evalNode(e.c, scope, m)) ? evalNode(e.a, scope, m) : evalNode(e.b, scope, m);
|
|
1021
|
+
case "get": {
|
|
1022
|
+
let v = evalNode(e.base, scope, m);
|
|
1023
|
+
for (const seg of e.segs) {
|
|
1024
|
+
if (v === null || v === undefined) return undefined;
|
|
1025
|
+
const key = seg.k === "name" ? seg.v : evalNode(seg.e, scope, m);
|
|
1026
|
+
v = typeof key === "string" || typeof key === "number" ? getProp(v, key) : undefined;
|
|
1027
|
+
}
|
|
1028
|
+
return v;
|
|
1029
|
+
}
|
|
1030
|
+
case "call":
|
|
1031
|
+
return call(
|
|
1032
|
+
e.fn,
|
|
1033
|
+
e.args.map((x) => evalNode(x, scope, m)),
|
|
1034
|
+
m,
|
|
1035
|
+
);
|
|
1036
|
+
case "bin": {
|
|
1037
|
+
if (e.op === "&&") {
|
|
1038
|
+
const a = evalNode(e.a, scope, m);
|
|
1039
|
+
return truthy(a) ? evalNode(e.b, scope, m) : a;
|
|
1040
|
+
}
|
|
1041
|
+
if (e.op === "||") {
|
|
1042
|
+
const a = evalNode(e.a, scope, m);
|
|
1043
|
+
return truthy(a) ? a : evalNode(e.b, scope, m);
|
|
1044
|
+
}
|
|
1045
|
+
if (e.op === "??") {
|
|
1046
|
+
const a = evalNode(e.a, scope, m);
|
|
1047
|
+
return a === null || a === undefined ? evalNode(e.b, scope, m) : a;
|
|
1048
|
+
}
|
|
1049
|
+
const a = evalNode(e.a, scope, m);
|
|
1050
|
+
const b = evalNode(e.b, scope, m);
|
|
1051
|
+
switch (e.op) {
|
|
1052
|
+
case "==":
|
|
1053
|
+
return deepEqual(a, b, m);
|
|
1054
|
+
case "!=":
|
|
1055
|
+
return !deepEqual(a, b, m);
|
|
1056
|
+
case "<":
|
|
1057
|
+
case "<=":
|
|
1058
|
+
case ">":
|
|
1059
|
+
case ">=":
|
|
1060
|
+
return cmp(e.op, a, b);
|
|
1061
|
+
case "+":
|
|
1062
|
+
if (typeof a === "number" && typeof b === "number") return a + b;
|
|
1063
|
+
if (typeof a === "string" || typeof b === "string") return capText(toText(a) + toText(b));
|
|
1064
|
+
throw new ExprError("+ adds two numbers or joins text");
|
|
1065
|
+
case "-":
|
|
1066
|
+
return num(a, "-") - num(b, "-");
|
|
1067
|
+
case "*":
|
|
1068
|
+
return num(a, "*") * num(b, "*");
|
|
1069
|
+
case "/": {
|
|
1070
|
+
const d = num(b, "/");
|
|
1071
|
+
if (d === 0) throw new ExprError("cannot divide by zero");
|
|
1072
|
+
return num(a, "/") / d;
|
|
1073
|
+
}
|
|
1074
|
+
case "%": {
|
|
1075
|
+
const d = num(b, "%");
|
|
1076
|
+
if (d === 0) throw new ExprError("cannot divide by zero");
|
|
1077
|
+
return num(a, "%") % d;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
throw new ExprError(`unknown operator ${e.op}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Evaluate a parsed expression. Throws ExprError on a mistake. */
|
|
1086
|
+
export function evalExpression(e: Expr, scope: Scope, meter: Meter = { left: AUTOMATION_LIMITS.evalWork }): unknown {
|
|
1087
|
+
return evalNode(e, scope, meter);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Evaluate a template: one expression keeps its type, a mix is text. */
|
|
1091
|
+
export function evalTemplate(t: Template, scope: Scope, meter: Meter = { left: AUTOMATION_LIMITS.evalWork }): unknown {
|
|
1092
|
+
if (t.single) return evalNode(t.single, scope, meter);
|
|
1093
|
+
let out = "";
|
|
1094
|
+
for (const p of t.parts) {
|
|
1095
|
+
out += typeof p === "string" ? p : toText(evalNode(p, scope, meter));
|
|
1096
|
+
capText(out);
|
|
1097
|
+
}
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
export interface ResolveOptions {
|
|
1102
|
+
/** A whole-value expression with no value is a MappingError. */
|
|
1103
|
+
strict?: boolean;
|
|
1104
|
+
/** Shared across values so a step's work is bounded as a whole. */
|
|
1105
|
+
meter?: Meter;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Resolve every `{{ }}` in a step's value (strings, lists, objects).
|
|
1109
|
+
* Keys are never templates. */
|
|
1110
|
+
export function resolveValue(value: unknown, scope: Scope, opts: ResolveOptions = {}): unknown {
|
|
1111
|
+
const meter = opts.meter ?? { left: AUTOMATION_LIMITS.evalWork };
|
|
1112
|
+
const walk = (v: unknown, depth: number): unknown => {
|
|
1113
|
+
spend(meter);
|
|
1114
|
+
if (depth > AUTOMATION_LIMITS.valueDepth + 2) throw new ExprError("that value nests too deep");
|
|
1115
|
+
if (typeof v === "string") {
|
|
1116
|
+
if (!v.includes("{{")) return v;
|
|
1117
|
+
const tpl = parseTemplate(v);
|
|
1118
|
+
const out = evalTemplate(tpl, scope, meter);
|
|
1119
|
+
if (opts.strict && out === undefined) throw new MappingError(tpl.sources[0] ?? "a reference");
|
|
1120
|
+
return out;
|
|
1121
|
+
}
|
|
1122
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1));
|
|
1123
|
+
if (isObject(v)) {
|
|
1124
|
+
const out: Record<string, unknown> = {};
|
|
1125
|
+
for (const k of Object.keys(v)) {
|
|
1126
|
+
if (BAD_KEYS.has(k)) continue;
|
|
1127
|
+
out[k] = walk(v[k], depth + 1);
|
|
1128
|
+
}
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
return v;
|
|
1132
|
+
};
|
|
1133
|
+
return walk(value, 0);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** One expression of a step (`if`, `over`, `until`, `collect`), evaluated. */
|
|
1137
|
+
export function evalSingle(text: string, scope: Scope, opts: ResolveOptions = {}): unknown {
|
|
1138
|
+
const meter = opts.meter ?? { left: AUTOMATION_LIMITS.evalWork };
|
|
1139
|
+
return evalNode(parseSingle(text), scope, meter);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/* ------------------------------------------------------------------ */
|
|
1143
|
+
/* References */
|
|
1144
|
+
/* ------------------------------------------------------------------ */
|
|
1145
|
+
|
|
1146
|
+
/** What an expression reads, for the save-time check: a root and the
|
|
1147
|
+
* static names after it (stops at the first computed bracket). */
|
|
1148
|
+
export interface Ref {
|
|
1149
|
+
root: Root;
|
|
1150
|
+
names: string[];
|
|
1151
|
+
/** The path had a computed bracket after `names`. */
|
|
1152
|
+
open: boolean;
|
|
1153
|
+
/** The expression as the person wrote it. */
|
|
1154
|
+
source: string;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function refsOfExpr(e: Expr, source: string, out: Ref[]): void {
|
|
1158
|
+
switch (e.n) {
|
|
1159
|
+
case "lit":
|
|
1160
|
+
return;
|
|
1161
|
+
case "path": {
|
|
1162
|
+
const names: string[] = [];
|
|
1163
|
+
let open = false;
|
|
1164
|
+
for (const s of e.segs) {
|
|
1165
|
+
if (s.k === "name") names.push(s.v);
|
|
1166
|
+
else {
|
|
1167
|
+
open = true;
|
|
1168
|
+
refsOfExpr(s.e, source, out);
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
out.push({ root: e.root, names, open, source });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
case "un":
|
|
1176
|
+
return refsOfExpr(e.a, source, out);
|
|
1177
|
+
case "get":
|
|
1178
|
+
refsOfExpr(e.base, source, out);
|
|
1179
|
+
for (const s of e.segs) if (s.k === "idx") refsOfExpr(s.e, source, out);
|
|
1180
|
+
return;
|
|
1181
|
+
case "bin":
|
|
1182
|
+
refsOfExpr(e.a, source, out);
|
|
1183
|
+
return refsOfExpr(e.b, source, out);
|
|
1184
|
+
case "tern":
|
|
1185
|
+
refsOfExpr(e.c, source, out);
|
|
1186
|
+
refsOfExpr(e.a, source, out);
|
|
1187
|
+
return refsOfExpr(e.b, source, out);
|
|
1188
|
+
case "call":
|
|
1189
|
+
for (const a of e.args) refsOfExpr(a, source, out);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Every reference in a template string. Throws ExprError. */
|
|
1194
|
+
export function refsOfText(text: string): Ref[] {
|
|
1195
|
+
if (!text.includes("{{")) return [];
|
|
1196
|
+
const tpl = parseTemplate(text);
|
|
1197
|
+
const out: Ref[] = [];
|
|
1198
|
+
tpl.parts.forEach((p) => {
|
|
1199
|
+
if (typeof p !== "string") refsOfExpr(p, "{{ … }}", out);
|
|
1200
|
+
});
|
|
1201
|
+
return out;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/* ------------------------------------------------------------------ */
|
|
1205
|
+
/* Output shapes (A8: the save-time check) */
|
|
1206
|
+
/* ------------------------------------------------------------------ */
|
|
1207
|
+
|
|
1208
|
+
/** The shape of a step's output, as far as a reference can be checked. */
|
|
1209
|
+
export type OutShape =
|
|
1210
|
+
| { type: "any" }
|
|
1211
|
+
| { type: "null" | "string" | "number" | "boolean" }
|
|
1212
|
+
| { type: "array"; items: OutShape }
|
|
1213
|
+
| { type: "object"; props: Record<string, OutShape>; open?: boolean };
|
|
1214
|
+
|
|
1215
|
+
const ANY: OutShape = { type: "any" };
|
|
1216
|
+
const str: OutShape = { type: "string" };
|
|
1217
|
+
const numShape: OutShape = { type: "number" };
|
|
1218
|
+
const bool: OutShape = { type: "boolean" };
|
|
1219
|
+
const obj = (props: Record<string, OutShape>, open = false): OutShape => ({ type: "object", props, ...(open ? { open: true } : {}) });
|
|
1220
|
+
const arr = (items: OutShape = ANY): OutShape => ({ type: "array", items });
|
|
1221
|
+
|
|
1222
|
+
/** A JSON Schema (the subset steps declare: type, properties, items) as a
|
|
1223
|
+
* shape. Anything it does not say is open. */
|
|
1224
|
+
export function shapeFromJsonSchema(schema: unknown, depth = 0): OutShape {
|
|
1225
|
+
if (!isObject(schema) || depth > 8) return ANY;
|
|
1226
|
+
const t = schema.type;
|
|
1227
|
+
if (t === "object" || isObject(schema.properties)) {
|
|
1228
|
+
const props: Record<string, OutShape> = {};
|
|
1229
|
+
if (isObject(schema.properties)) {
|
|
1230
|
+
for (const k of Object.keys(schema.properties)) if (!BAD_KEYS.has(k)) props[k] = shapeFromJsonSchema(schema.properties[k], depth + 1);
|
|
1231
|
+
}
|
|
1232
|
+
return obj(props, schema.additionalProperties !== false && Object.keys(props).length === 0 ? true : schema.additionalProperties === true);
|
|
1233
|
+
}
|
|
1234
|
+
if (t === "array") return arr(shapeFromJsonSchema(schema.items, depth + 1));
|
|
1235
|
+
if (t === "string" || t === "number" || t === "boolean" || t === "null") return { type: t };
|
|
1236
|
+
if (t === "integer") return numShape;
|
|
1237
|
+
return ANY;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Walk `names` down a shape. Null: fine. Else the words for the miss. */
|
|
1241
|
+
export function checkShapePath(shape: OutShape, names: readonly string[]): string | null {
|
|
1242
|
+
let cur: OutShape = shape;
|
|
1243
|
+
for (let i = 0; i < names.length; i++) {
|
|
1244
|
+
const name = names[i]!;
|
|
1245
|
+
if (cur.type === "any") return null;
|
|
1246
|
+
if (cur.type === "object") {
|
|
1247
|
+
if (hasOwn(cur.props, name)) {
|
|
1248
|
+
cur = cur.props[name]!;
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
if (cur.open) return null;
|
|
1252
|
+
const known = Object.keys(cur.props);
|
|
1253
|
+
return `has no "${name}"${known.length ? ` (it has ${known.slice(0, 8).join(", ")})` : ""}`;
|
|
1254
|
+
}
|
|
1255
|
+
if (cur.type === "array") {
|
|
1256
|
+
if (name === "length") return i === names.length - 1 ? null : "length is a number";
|
|
1257
|
+
return `is a list; use an index like [0] or list.map for "${name}"`;
|
|
1258
|
+
}
|
|
1259
|
+
if (cur.type === "string" && name === "length") return null;
|
|
1260
|
+
return `is ${cur.type === "null" ? "empty" : `a ${cur.type}`}, so it has no "${name}"`;
|
|
1261
|
+
}
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/* ------------------------------------------------------------------ */
|
|
1266
|
+
/* Validation */
|
|
1267
|
+
/* ------------------------------------------------------------------ */
|
|
1268
|
+
|
|
1269
|
+
export interface ValidateOptions {
|
|
1270
|
+
/** The kinds this engine runs. Default: every kind the format names. */
|
|
1271
|
+
kinds?: readonly StepKind[];
|
|
1272
|
+
/** Extra check of a cron expression in a zone: a message, or null.
|
|
1273
|
+
* (The shape is checked here; cron-parser is the engine's.) */
|
|
1274
|
+
cronCheck?: (cron: string, tz: string) => string | null;
|
|
1275
|
+
/** The app's declared action names: an `action` step must name one.
|
|
1276
|
+
* Absent: not checked (a workspace automation names other apps). */
|
|
1277
|
+
actionNames?: ReadonlySet<string> | null;
|
|
1278
|
+
/** The output shape of an action by name (from its declaration). */
|
|
1279
|
+
actionShape?: (name: string, app: string | undefined) => OutShape | null;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
export type ValidateResult = { ok: true; automation: AutomationDef } | { ok: false; problems: Problem[] };
|
|
1283
|
+
|
|
1284
|
+
const CRON_FIELD = /^[0-9*/,\-A-Za-z?LW#]+$/;
|
|
1285
|
+
|
|
1286
|
+
/** Checks `tz` is a zone the runtime knows (or "team"). */
|
|
1287
|
+
function zoneOk(tz: string): boolean {
|
|
1288
|
+
if (tz === "team") return true;
|
|
1289
|
+
try {
|
|
1290
|
+
new Intl.DateTimeFormat("en", { timeZone: tz });
|
|
1291
|
+
return true;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const STEP_KEYS: Record<StepKind, readonly string[]> = {
|
|
1298
|
+
action: ["action", "app", "input"],
|
|
1299
|
+
"brain.read": ["op", "input"],
|
|
1300
|
+
condition: ["if", "then", "else"],
|
|
1301
|
+
loop: ["over", "max", "concurrency", "collect", "steps"],
|
|
1302
|
+
set: ["values"],
|
|
1303
|
+
wait: ["for", "until"],
|
|
1304
|
+
stop: ["message", "as"],
|
|
1305
|
+
notify: ["message", "to"],
|
|
1306
|
+
tool: ["needs", "tool", "input", "account"],
|
|
1307
|
+
search: ["query", "limit"],
|
|
1308
|
+
llm: ["prompt", "system", "tier", "schema"],
|
|
1309
|
+
agent: ["instructions", "cap_credits", "schema"],
|
|
1310
|
+
zero: ["capability", "url", "method", "input", "max_pay_usd"],
|
|
1311
|
+
automation: ["key", "input"],
|
|
1312
|
+
};
|
|
1313
|
+
const COMMON_KEYS = ["id", "kind", "label", "on_error", "retry"];
|
|
1314
|
+
|
|
1315
|
+
interface Env {
|
|
1316
|
+
/** Steps whose output a reference may read here, in order. */
|
|
1317
|
+
steps: Map<string, OutShape>;
|
|
1318
|
+
/** Inside a loop: item and index exist. */
|
|
1319
|
+
itemShape: OutShape | null;
|
|
1320
|
+
trigger: Trigger;
|
|
1321
|
+
inputs: Record<string, InputSpec>;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
class Validator {
|
|
1325
|
+
problems: Problem[] = [];
|
|
1326
|
+
private ids = new Set<string>();
|
|
1327
|
+
private count = 0;
|
|
1328
|
+
private readonly opts: ValidateOptions;
|
|
1329
|
+
/** The key of the automation being validated, once read (a trigger may
|
|
1330
|
+
* not follow itself). */
|
|
1331
|
+
private selfKey = "";
|
|
1332
|
+
|
|
1333
|
+
constructor(opts: ValidateOptions) {
|
|
1334
|
+
this.opts = opts;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
add(path: string, message: string): void {
|
|
1338
|
+
if (this.problems.length < 60) this.problems.push({ path, message });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
run(raw: unknown): AutomationDef | null {
|
|
1342
|
+
if (!isObject(raw)) {
|
|
1343
|
+
this.add("", "an automation is a JSON object");
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
this.allowKeys(raw, ["format", "key", "name", "does", "trigger", "inputs", "settings", "steps"], "");
|
|
1347
|
+
if (raw.format !== AUTOMATION_FORMAT) this.add("format", `format must be ${AUTOMATION_FORMAT}`);
|
|
1348
|
+
const key = this.text(raw.key, "key", 1, 60, true);
|
|
1349
|
+
if (key && !AUTOMATION_KEY.test(key)) this.add("key", "key is lower case letters, digits and hyphens, starting with a letter");
|
|
1350
|
+
this.selfKey = key ?? "";
|
|
1351
|
+
const name = this.text(raw.name, "name", 1, AUTOMATION_LIMITS.nameChars, true);
|
|
1352
|
+
const does = raw.does === undefined ? undefined : (this.text(raw.does, "does", 0, AUTOMATION_LIMITS.doesChars, false) ?? undefined);
|
|
1353
|
+
const trigger = this.trigger(raw.trigger);
|
|
1354
|
+
const inputs = this.inputs(raw.inputs);
|
|
1355
|
+
// A schedule or a press waits its turn (skip); a trigger that is something
|
|
1356
|
+
// HAPPENING must not lose it while a run is under way, so it queues.
|
|
1357
|
+
const settings = this.settings(raw.settings, trigger && trigger.type !== "schedule" && trigger.type !== "manual" ? "queue" : "skip");
|
|
1358
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) this.add("steps", "steps is a list with at least one step");
|
|
1359
|
+
|
|
1360
|
+
const env: Env = { steps: new Map(), itemShape: null, trigger: trigger ?? { type: "manual" }, inputs };
|
|
1361
|
+
const steps = Array.isArray(raw.steps) ? this.steps(raw.steps, "steps", env, 1) : [];
|
|
1362
|
+
if (this.problems.length || !key || !name || !trigger) return null;
|
|
1363
|
+
return {
|
|
1364
|
+
format: 1,
|
|
1365
|
+
key,
|
|
1366
|
+
name,
|
|
1367
|
+
...(does ? { does } : {}),
|
|
1368
|
+
trigger,
|
|
1369
|
+
inputs,
|
|
1370
|
+
settings,
|
|
1371
|
+
steps,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
private allowKeys(o: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
|
1376
|
+
for (const k of Object.keys(o)) {
|
|
1377
|
+
if (BAD_KEYS.has(k) || !allowed.includes(k)) this.add(path ? `${path}.${k}` : k, `"${k}" is not a field here (${allowed.join(", ")})`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
private text(v: unknown, path: string, min: number, max: number, required: boolean): string | null {
|
|
1382
|
+
if (v === undefined || v === null) {
|
|
1383
|
+
if (required) this.add(path, `${path.split(".").pop()} is required`);
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
if (typeof v !== "string") {
|
|
1387
|
+
this.add(path, "must be text");
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
const t = v.trim();
|
|
1391
|
+
if (t.length < min || (required && t.length === 0)) {
|
|
1392
|
+
this.add(path, `${path.split(".").pop()} is required`);
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
if (t.length > max) {
|
|
1396
|
+
this.add(path, `at most ${max} characters`);
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
return t;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private trigger(raw: unknown): Trigger | null {
|
|
1403
|
+
if (!isObject(raw)) {
|
|
1404
|
+
this.add("trigger", `trigger is required: { type: ${TRIGGER_TYPES.join(" | ")} }`);
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
const type = raw.type;
|
|
1408
|
+
if (type === "schedule") {
|
|
1409
|
+
this.allowKeys(raw, ["type", "cron", "tz"], "trigger");
|
|
1410
|
+
const cron = this.text(raw.cron, "trigger.cron", 1, 100, true);
|
|
1411
|
+
const tzRaw = raw.tz === undefined ? "team" : raw.tz;
|
|
1412
|
+
let tz = "team";
|
|
1413
|
+
if (typeof tzRaw !== "string" || !zoneOk(tzRaw)) this.add("trigger.tz", 'tz is "team" or a time zone like "Europe/London"');
|
|
1414
|
+
else tz = tzRaw;
|
|
1415
|
+
if (cron) {
|
|
1416
|
+
const fields = cron.split(/\s+/);
|
|
1417
|
+
if (fields.length !== 5 || !fields.every((f) => CRON_FIELD.test(f))) this.add("trigger.cron", "cron is five fields: minute hour day month weekday");
|
|
1418
|
+
else {
|
|
1419
|
+
const extra = this.opts.cronCheck?.(cron, tz);
|
|
1420
|
+
if (extra) this.add("trigger.cron", extra);
|
|
1421
|
+
}
|
|
1422
|
+
return fields.length === 5 ? { type: "schedule", cron: fields.join(" "), tz } : null;
|
|
1423
|
+
}
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
if (type === "manual") {
|
|
1427
|
+
this.allowKeys(raw, ["type"], "trigger");
|
|
1428
|
+
return { type: "manual" };
|
|
1429
|
+
}
|
|
1430
|
+
if (type === "event") {
|
|
1431
|
+
this.allowKeys(raw, ["type", "topic", "app"], "trigger");
|
|
1432
|
+
const topic = this.text(raw.topic, "trigger.topic", 1, 64, true);
|
|
1433
|
+
if (topic && !EVENT_TOPIC.test(topic)) this.add("trigger.topic", "topic is a short dotted slug, like rows.imported");
|
|
1434
|
+
const app = this.appName(raw.app);
|
|
1435
|
+
return topic && EVENT_TOPIC.test(topic) ? { type: "event", topic, ...(app ? { app } : {}) } : null;
|
|
1436
|
+
}
|
|
1437
|
+
if (type === "action") {
|
|
1438
|
+
this.allowKeys(raw, ["type", "action", "app"], "trigger");
|
|
1439
|
+
const action = this.text(raw.action, "trigger.action", 1, 40, true);
|
|
1440
|
+
if (action && !AUTOMATION_ID.test(action)) this.add("trigger.action", "action is the name of one of the app's declared actions");
|
|
1441
|
+
else if (action && this.opts.actionNames && !this.opts.actionNames.has(action))
|
|
1442
|
+
this.add("trigger.action", `this app declares no action "${action}" (it declares: ${[...this.opts.actionNames].slice(0, 12).join(", ") || "none"})`);
|
|
1443
|
+
const app = this.appName(raw.app);
|
|
1444
|
+
return action && AUTOMATION_ID.test(action) ? { type: "action", action, ...(app ? { app } : {}) } : null;
|
|
1445
|
+
}
|
|
1446
|
+
if (type === "record") {
|
|
1447
|
+
this.allowKeys(raw, ["type", "kinds", "ops", "app"], "trigger");
|
|
1448
|
+
let kinds: string[] | undefined;
|
|
1449
|
+
if (raw.kinds !== undefined) {
|
|
1450
|
+
if (!Array.isArray(raw.kinds) || raw.kinds.length === 0 || raw.kinds.length > 20 || !raw.kinds.every((k) => typeof k === "string" && BRAIN_KIND_NAME.test(k)))
|
|
1451
|
+
this.add("trigger.kinds", "kinds is a list of up to 20 kind names (lower case, like contact), or leave it out for every kind");
|
|
1452
|
+
else kinds = [...new Set(raw.kinds as string[])];
|
|
1453
|
+
}
|
|
1454
|
+
let ops: ("create" | "update")[] | undefined;
|
|
1455
|
+
if (raw.ops !== undefined) {
|
|
1456
|
+
if (!Array.isArray(raw.ops) || raw.ops.length === 0 || !raw.ops.every((o) => (RECORD_OPS as readonly unknown[]).includes(o)))
|
|
1457
|
+
this.add("trigger.ops", `ops is a list of ${RECORD_OPS.join(" and/or ")}, or leave it out for both`);
|
|
1458
|
+
else ops = [...new Set(raw.ops as ("create" | "update")[])];
|
|
1459
|
+
}
|
|
1460
|
+
const app = this.appName(raw.app);
|
|
1461
|
+
return { type: "record", ...(kinds ? { kinds } : {}), ...(ops ? { ops } : {}), ...(app ? { app } : {}) };
|
|
1462
|
+
}
|
|
1463
|
+
if (type === "webhook") {
|
|
1464
|
+
this.allowKeys(raw, ["type"], "trigger");
|
|
1465
|
+
return { type: "webhook" };
|
|
1466
|
+
}
|
|
1467
|
+
if (type === "automation") {
|
|
1468
|
+
this.allowKeys(raw, ["type", "key", "when"], "trigger");
|
|
1469
|
+
const key = this.text(raw.key, "trigger.key", 1, 60, true);
|
|
1470
|
+
if (key && !AUTOMATION_KEY.test(key)) this.add("trigger.key", "key names another automation: lower case letters, digits and hyphens");
|
|
1471
|
+
let when: "done" | "failed" | "any" = "done";
|
|
1472
|
+
if (raw.when !== undefined) {
|
|
1473
|
+
if ((FINISH_WHEN as readonly unknown[]).includes(raw.when)) when = raw.when as typeof when;
|
|
1474
|
+
else this.add("trigger.when", `when is one of ${FINISH_WHEN.join(", ")}`);
|
|
1475
|
+
}
|
|
1476
|
+
if (key && AUTOMATION_KEY.test(key) && this.selfKey && key === this.selfKey) this.add("trigger.key", "an automation cannot start itself: pick another automation to follow");
|
|
1477
|
+
return key && AUTOMATION_KEY.test(key) ? { type: "automation", key, when } : null;
|
|
1478
|
+
}
|
|
1479
|
+
if (typeof type === "string" && hasOwn(REFUSED_TRIGGER_TYPES, type)) {
|
|
1480
|
+
this.add("trigger.type", REFUSED_TRIGGER_TYPES[type]!);
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
this.add("trigger.type", `trigger type is one of ${TRIGGER_TYPES.join(", ")}`);
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/** The app a trigger names (a workspace automation says whose events it
|
|
1488
|
+
* hears): its name or template key. */
|
|
1489
|
+
private appName(raw: unknown): string | undefined {
|
|
1490
|
+
if (raw === undefined) return undefined;
|
|
1491
|
+
return this.text(raw, "trigger.app", 1, 80, false) ?? undefined;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private inputs(raw: unknown): Record<string, InputSpec> {
|
|
1495
|
+
const out: Record<string, InputSpec> = {};
|
|
1496
|
+
if (raw === undefined) return out;
|
|
1497
|
+
if (!isObject(raw)) {
|
|
1498
|
+
this.add("inputs", "inputs is an object of named inputs");
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
const names = Object.keys(raw);
|
|
1502
|
+
if (names.length > AUTOMATION_LIMITS.inputs) this.add("inputs", `at most ${AUTOMATION_LIMITS.inputs} inputs`);
|
|
1503
|
+
for (const n of names) {
|
|
1504
|
+
const p = `inputs.${n}`;
|
|
1505
|
+
if (!AUTOMATION_ID.test(n)) {
|
|
1506
|
+
this.add(p, "an input's name is lower case letters, digits and underscores");
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
const spec = raw[n];
|
|
1510
|
+
if (!isObject(spec)) {
|
|
1511
|
+
this.add(p, "an input is { type, label?, required?, default?, options? }");
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
this.allowKeys(spec, ["type", "label", "required", "default", "options"], p);
|
|
1515
|
+
const type = spec.type;
|
|
1516
|
+
if (type !== "string" && type !== "number" && type !== "boolean" && type !== "date") {
|
|
1517
|
+
this.add(`${p}.type`, "type is string, number, boolean or date");
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
const item: InputSpec = { type };
|
|
1521
|
+
if (spec.label !== undefined) item.label = this.text(spec.label, `${p}.label`, 0, AUTOMATION_LIMITS.nameChars, false) ?? undefined;
|
|
1522
|
+
if (spec.required !== undefined) {
|
|
1523
|
+
if (typeof spec.required !== "boolean") this.add(`${p}.required`, "true or false");
|
|
1524
|
+
else item.required = spec.required;
|
|
1525
|
+
}
|
|
1526
|
+
if (spec.default !== undefined) {
|
|
1527
|
+
const d = spec.default;
|
|
1528
|
+
if (typeof d === "string" || typeof d === "number" || typeof d === "boolean") item.default = d;
|
|
1529
|
+
else this.add(`${p}.default`, "a text, number or true/false");
|
|
1530
|
+
}
|
|
1531
|
+
if (spec.options !== undefined) {
|
|
1532
|
+
if (!Array.isArray(spec.options) || spec.options.length === 0 || spec.options.length > 30 || !spec.options.every((o) => typeof o === "string"))
|
|
1533
|
+
this.add(`${p}.options`, "a list of up to 30 texts");
|
|
1534
|
+
else item.options = spec.options as string[];
|
|
1535
|
+
}
|
|
1536
|
+
out[n] = item;
|
|
1537
|
+
}
|
|
1538
|
+
return out;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
private settings(raw: unknown, overlap: "skip" | "queue" = "skip"): AutomationSettings {
|
|
1542
|
+
const out: AutomationSettings = { overlap, on_failure: "retry_then_agent", only_if_changed: false };
|
|
1543
|
+
if (raw === undefined) return out;
|
|
1544
|
+
if (!isObject(raw)) {
|
|
1545
|
+
this.add("settings", "settings is an object");
|
|
1546
|
+
return out;
|
|
1547
|
+
}
|
|
1548
|
+
this.allowKeys(raw, ["overlap", "on_failure", "only_if_changed", "run_cap_credits", "max_per_hour"], "settings");
|
|
1549
|
+
if (raw.overlap !== undefined) {
|
|
1550
|
+
if ((OVERLAP_MODES as readonly unknown[]).includes(raw.overlap)) out.overlap = raw.overlap as AutomationSettings["overlap"];
|
|
1551
|
+
else this.add("settings.overlap", `overlap is one of ${OVERLAP_MODES.join(", ")}`);
|
|
1552
|
+
}
|
|
1553
|
+
if (raw.on_failure !== undefined) {
|
|
1554
|
+
if ((FAILURE_MODES as readonly unknown[]).includes(raw.on_failure)) out.on_failure = raw.on_failure as AutomationSettings["on_failure"];
|
|
1555
|
+
else this.add("settings.on_failure", `on_failure is one of ${FAILURE_MODES.join(", ")}`);
|
|
1556
|
+
}
|
|
1557
|
+
if (raw.only_if_changed !== undefined) {
|
|
1558
|
+
if (typeof raw.only_if_changed === "boolean") out.only_if_changed = raw.only_if_changed;
|
|
1559
|
+
else this.add("settings.only_if_changed", "true or false");
|
|
1560
|
+
}
|
|
1561
|
+
if (raw.run_cap_credits !== undefined) {
|
|
1562
|
+
if (typeof raw.run_cap_credits === "number" && Number.isFinite(raw.run_cap_credits) && raw.run_cap_credits >= 0 && raw.run_cap_credits <= 1_000_000)
|
|
1563
|
+
out.run_cap_credits = raw.run_cap_credits;
|
|
1564
|
+
else this.add("settings.run_cap_credits", "credits, from 0 to 1,000,000");
|
|
1565
|
+
}
|
|
1566
|
+
if (raw.max_per_hour !== undefined) {
|
|
1567
|
+
if (typeof raw.max_per_hour === "number" && Number.isInteger(raw.max_per_hour) && raw.max_per_hour >= 1 && raw.max_per_hour <= 600) out.max_per_hour = raw.max_per_hour;
|
|
1568
|
+
else this.add("settings.max_per_hour", "a whole number of runs an hour, from 1 to 600");
|
|
1569
|
+
}
|
|
1570
|
+
return out;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
private steps(list: unknown[], path: string, env: Env, depth: number): Step[] {
|
|
1574
|
+
const out: Step[] = [];
|
|
1575
|
+
if (depth > AUTOMATION_LIMITS.depth) {
|
|
1576
|
+
this.add(path, `steps nest at most ${AUTOMATION_LIMITS.depth} deep`);
|
|
1577
|
+
return out;
|
|
1578
|
+
}
|
|
1579
|
+
// A step reads what came before it in this list and above; its own
|
|
1580
|
+
// nested lists add nothing visible outside them.
|
|
1581
|
+
const local: Env = { ...env, steps: new Map(env.steps) };
|
|
1582
|
+
list.forEach((raw, i) => {
|
|
1583
|
+
const p = `${path}[${i}]`;
|
|
1584
|
+
const step = this.step(raw, p, local, depth);
|
|
1585
|
+
if (step) out.push(step);
|
|
1586
|
+
});
|
|
1587
|
+
return out;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
private step(raw: unknown, path: string, env: Env, depth: number): Step | null {
|
|
1591
|
+
if (!isObject(raw)) {
|
|
1592
|
+
this.add(path, "a step is an object");
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
if (++this.count > AUTOMATION_LIMITS.steps) {
|
|
1596
|
+
if (this.count === AUTOMATION_LIMITS.steps + 1) this.add(path, `at most ${AUTOMATION_LIMITS.steps} steps in one automation`);
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
const id = typeof raw.id === "string" ? raw.id : "";
|
|
1600
|
+
if (!AUTOMATION_ID.test(id)) this.add(`${path}.id`, "a step's id is lower case letters, digits and underscores, starting with a letter");
|
|
1601
|
+
else if (this.ids.has(id)) this.add(`${path}.id`, `the id "${id}" is used twice; ids are unique in an automation`);
|
|
1602
|
+
else this.ids.add(id);
|
|
1603
|
+
|
|
1604
|
+
const kind = raw.kind;
|
|
1605
|
+
if (typeof kind === "string" && hasOwn(REFUSED_STEP_KINDS, kind)) {
|
|
1606
|
+
this.add(`${path}.kind`, REFUSED_STEP_KINDS[kind]!);
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
if (typeof kind !== "string" || !(STEP_KINDS as readonly string[]).includes(kind)) {
|
|
1610
|
+
this.add(`${path}.kind`, `kind is one of ${(this.opts.kinds ?? STEP_KINDS).join(", ")}`);
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
const k = kind as StepKind;
|
|
1614
|
+
if (this.opts.kinds && !this.opts.kinds.includes(k)) {
|
|
1615
|
+
this.add(`${path}.kind`, `${k} steps are not available yet (${this.opts.kinds.join(", ")} are)`);
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
this.allowKeys(raw, [...COMMON_KEYS, ...STEP_KEYS[k]], path);
|
|
1619
|
+
|
|
1620
|
+
const base: StepBase = { id };
|
|
1621
|
+
if (raw.label !== undefined) {
|
|
1622
|
+
const label = this.text(raw.label, `${path}.label`, 0, AUTOMATION_LIMITS.nameChars, false);
|
|
1623
|
+
if (label) base.label = label;
|
|
1624
|
+
}
|
|
1625
|
+
if (raw.on_error !== undefined) {
|
|
1626
|
+
if (raw.on_error === "stop" || raw.on_error === "continue") base.on_error = raw.on_error;
|
|
1627
|
+
else this.add(`${path}.on_error`, 'on_error is "stop" or "continue"');
|
|
1628
|
+
}
|
|
1629
|
+
if (raw.retry !== undefined) {
|
|
1630
|
+
const r = raw.retry;
|
|
1631
|
+
if (isObject(r) && Object.keys(r).length === 1 && Number.isInteger(r.max) && (r.max as number) >= 0 && (r.max as number) <= AUTOMATION_LIMITS.retries)
|
|
1632
|
+
base.retry = { max: r.max as number };
|
|
1633
|
+
else this.add(`${path}.retry`, `retry is { max: 0 to ${AUTOMATION_LIMITS.retries} }`);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
let step: Step | null = null;
|
|
1637
|
+
let shape: OutShape = ANY;
|
|
1638
|
+
switch (k) {
|
|
1639
|
+
case "action": {
|
|
1640
|
+
const action = typeof raw.action === "string" ? raw.action : "";
|
|
1641
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(action)) this.add(`${path}.action`, "action names one of the app's declared actions");
|
|
1642
|
+
else if (this.opts.actionNames && raw.app === undefined && !this.opts.actionNames.has(action))
|
|
1643
|
+
this.add(`${path}.action`, `the app declares no action "${action}"${this.opts.actionNames.size ? ` (it declares ${[...this.opts.actionNames].slice(0, 10).join(", ")})` : ""}`);
|
|
1644
|
+
let app: string | undefined;
|
|
1645
|
+
if (raw.app !== undefined) app = this.text(raw.app, `${path}.app`, 1, 60, true) ?? undefined;
|
|
1646
|
+
const input = this.value(raw.input, `${path}.input`, env, true);
|
|
1647
|
+
const given = this.opts.actionShape?.(action, app);
|
|
1648
|
+
shape =
|
|
1649
|
+
given ??
|
|
1650
|
+
obj({ ok: bool, action: str, result: ANY, records: ANY, created: numShape, changed: numShape });
|
|
1651
|
+
step = { ...base, kind: "action", action, ...(app ? { app } : {}), ...(input ? { input: input as { [key: string]: StepValue } } : {}) };
|
|
1652
|
+
break;
|
|
1653
|
+
}
|
|
1654
|
+
case "brain.read": {
|
|
1655
|
+
const op = raw.op;
|
|
1656
|
+
if (op !== "search" && op !== "get" && op !== "list") this.add(`${path}.op`, "op is search, get or list");
|
|
1657
|
+
const input = this.value(raw.input, `${path}.input`, env, false);
|
|
1658
|
+
if (!input || !isObject(input)) this.add(`${path}.input`, "input is an object (search: q; get: id; list: kind)");
|
|
1659
|
+
shape =
|
|
1660
|
+
op === "get"
|
|
1661
|
+
? obj({ ok: bool, brain: ANY, record: ANY })
|
|
1662
|
+
: op === "list"
|
|
1663
|
+
? obj({ ok: bool, brain: ANY, records: arr(ANY), next: ANY })
|
|
1664
|
+
: obj({ ok: bool, brain: ANY, meaning: ANY, results: arr(ANY) });
|
|
1665
|
+
step = { ...base, kind: "brain.read", op: op as BrainReadStep["op"], input: (input ?? {}) as { [key: string]: StepValue } };
|
|
1666
|
+
break;
|
|
1667
|
+
}
|
|
1668
|
+
case "condition": {
|
|
1669
|
+
const cond = this.single(raw.if, `${path}.if`, env, true);
|
|
1670
|
+
const hasThen = Array.isArray(raw.then) && raw.then.length > 0;
|
|
1671
|
+
const hasElse = Array.isArray(raw.else) && raw.else.length > 0;
|
|
1672
|
+
if (raw.then !== undefined && !Array.isArray(raw.then)) this.add(`${path}.then`, "then is a list of steps");
|
|
1673
|
+
if (raw.else !== undefined && !Array.isArray(raw.else)) this.add(`${path}.else`, "else is a list of steps");
|
|
1674
|
+
if (!hasThen && !hasElse) this.add(path, "a condition needs steps in then, else or both");
|
|
1675
|
+
const thenSteps = hasThen ? this.steps(raw.then as unknown[], `${path}.then`, env, depth + 1) : undefined;
|
|
1676
|
+
const elseSteps = hasElse ? this.steps(raw.else as unknown[], `${path}.else`, env, depth + 1) : undefined;
|
|
1677
|
+
shape = obj({ taken: str });
|
|
1678
|
+
step = { ...base, kind: "condition", if: cond ?? "", ...(thenSteps ? { then: thenSteps } : {}), ...(elseSteps ? { else: elseSteps } : {}) };
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
case "loop": {
|
|
1682
|
+
const over = this.single(raw.over, `${path}.over`, env, true);
|
|
1683
|
+
let max: number = AUTOMATION_LIMITS.loopItems;
|
|
1684
|
+
if (raw.max !== undefined) {
|
|
1685
|
+
if (Number.isInteger(raw.max) && (raw.max as number) >= 1 && (raw.max as number) <= AUTOMATION_LIMITS.loopItems) max = raw.max as number;
|
|
1686
|
+
else this.add(`${path}.max`, `max is a whole number from 1 to ${AUTOMATION_LIMITS.loopItems}`);
|
|
1687
|
+
}
|
|
1688
|
+
if (raw.concurrency !== undefined && raw.concurrency !== 1) this.add(`${path}.concurrency`, "a loop runs one item at a time (concurrency 1)");
|
|
1689
|
+
// The item's shape: the list's items when `over` names a known list.
|
|
1690
|
+
const itemShape = over ? this.itemShapeOf(over, env) : ANY;
|
|
1691
|
+
const inner: Env = { ...env, itemShape };
|
|
1692
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) this.add(`${path}.steps`, "a loop has steps");
|
|
1693
|
+
const body = Array.isArray(raw.steps) ? this.steps(raw.steps, `${path}.steps`, inner, depth + 1) : [];
|
|
1694
|
+
let collect: string | undefined;
|
|
1695
|
+
if (raw.collect !== undefined) {
|
|
1696
|
+
// Read what the loop's last pass made: the body's steps are visible.
|
|
1697
|
+
const bodyEnv = this.afterBody(inner, body);
|
|
1698
|
+
collect = this.single(raw.collect, `${path}.collect`, bodyEnv, true) ?? undefined;
|
|
1699
|
+
}
|
|
1700
|
+
shape = obj({ count: numShape, collected: arr(ANY) });
|
|
1701
|
+
step = { ...base, kind: "loop", over: over ?? "", max, ...(collect ? { collect } : {}), steps: body };
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
case "set": {
|
|
1705
|
+
const values = this.value(raw.values, `${path}.values`, env, false);
|
|
1706
|
+
const props: Record<string, OutShape> = {};
|
|
1707
|
+
if (!values || !isObject(values) || Object.keys(values).length === 0) this.add(`${path}.values`, "values is an object with at least one value");
|
|
1708
|
+
else {
|
|
1709
|
+
for (const key of Object.keys(values)) {
|
|
1710
|
+
if (!AUTOMATION_ID.test(key)) this.add(`${path}.values.${key}`, "a value's name is lower case letters, digits and underscores");
|
|
1711
|
+
props[key] = ANY;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
shape = obj(props);
|
|
1715
|
+
step = { ...base, kind: "set", values: (values ?? {}) as { [key: string]: StepValue } };
|
|
1716
|
+
break;
|
|
1717
|
+
}
|
|
1718
|
+
case "wait": {
|
|
1719
|
+
const hasFor = raw.for !== undefined;
|
|
1720
|
+
const hasUntil = raw.until !== undefined;
|
|
1721
|
+
if (hasFor === hasUntil) this.add(path, "a wait has either for (a duration like 10m) or until (a time)");
|
|
1722
|
+
let forText: string | undefined;
|
|
1723
|
+
if (hasFor) {
|
|
1724
|
+
const ms = durationMs(raw.for);
|
|
1725
|
+
if (ms === null) this.add(`${path}.for`, "for is a duration: a number and s, m, h or d (30s, 10m, 2h, 3d)");
|
|
1726
|
+
else if (ms > WAIT_MAX_MS) this.add(`${path}.for`, "a wait is at most 30 days");
|
|
1727
|
+
else forText = raw.for as string;
|
|
1728
|
+
}
|
|
1729
|
+
const until = hasUntil ? this.single(raw.until, `${path}.until`, env, true) : null;
|
|
1730
|
+
shape = obj({ waited_until: str });
|
|
1731
|
+
step = { ...base, kind: "wait", ...(forText ? { for: forText } : {}), ...(until ? { until } : {}) };
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
case "stop": {
|
|
1735
|
+
const message = this.templated(raw.message, `${path}.message`, env, true);
|
|
1736
|
+
if (raw.as !== undefined && raw.as !== "done" && raw.as !== "failed") this.add(`${path}.as`, 'as is "done" or "failed"');
|
|
1737
|
+
shape = obj({ message: str, as: str });
|
|
1738
|
+
step = { ...base, kind: "stop", message: message ?? "", ...(raw.as === "failed" ? { as: "failed" as const } : raw.as === "done" ? { as: "done" as const } : {}) };
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1741
|
+
case "notify": {
|
|
1742
|
+
const message = this.templated(raw.message, `${path}.message`, env, true);
|
|
1743
|
+
if (raw.to !== undefined && raw.to !== "owner") this.add(`${path}.to`, 'to is "owner"');
|
|
1744
|
+
shape = obj({ delivered: bool });
|
|
1745
|
+
step = { ...base, kind: "notify", message: message ?? "", ...(raw.to === "owner" ? { to: "owner" as const } : {}) };
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
case "tool": {
|
|
1749
|
+
const needs = raw.needs;
|
|
1750
|
+
const category = isObject(needs) && typeof needs.category === "string" ? needs.category : "";
|
|
1751
|
+
if (!/^[a-z][a-z0-9_-]{0,40}$/.test(category)) this.add(`${path}.needs`, "needs is { category: the kind of account, like mail }");
|
|
1752
|
+
const tool = this.text(raw.tool, `${path}.tool`, 1, 100, true);
|
|
1753
|
+
const input = this.value(raw.input, `${path}.input`, env, true);
|
|
1754
|
+
const account = raw.account === undefined ? undefined : (this.text(raw.account, `${path}.account`, 1, 100, true) ?? undefined);
|
|
1755
|
+
step = { ...base, kind: "tool", needs: { category }, tool: tool ?? "", ...(input ? { input: input as { [key: string]: StepValue } } : {}), ...(account ? { account } : {}) };
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
case "search": {
|
|
1759
|
+
const query = this.templated(raw.query, `${path}.query`, env, true);
|
|
1760
|
+
if (raw.limit !== undefined && !(Number.isInteger(raw.limit) && (raw.limit as number) >= 1 && (raw.limit as number) <= 20)) this.add(`${path}.limit`, "limit is 1 to 20");
|
|
1761
|
+
shape = obj({ results: arr(ANY) });
|
|
1762
|
+
step = { ...base, kind: "search", query: query ?? "", ...(Number.isInteger(raw.limit) ? { limit: raw.limit as number } : {}) };
|
|
1763
|
+
break;
|
|
1764
|
+
}
|
|
1765
|
+
case "llm": {
|
|
1766
|
+
const prompt = this.templated(raw.prompt, `${path}.prompt`, env, true);
|
|
1767
|
+
const system = raw.system === undefined ? undefined : (this.templated(raw.system, `${path}.system`, env, false) ?? undefined);
|
|
1768
|
+
if (raw.tier !== undefined && raw.tier !== "fast" && raw.tier !== "smart") this.add(`${path}.tier`, 'tier is "fast" or "smart"');
|
|
1769
|
+
let schema: Record<string, unknown> | undefined;
|
|
1770
|
+
if (raw.schema !== undefined) {
|
|
1771
|
+
if (!isObject(raw.schema) || canonicalJson(raw.schema).length > 16_000) this.add(`${path}.schema`, "schema is a JSON Schema object, up to 16 KB");
|
|
1772
|
+
else schema = raw.schema;
|
|
1773
|
+
}
|
|
1774
|
+
shape = schema ? obj({ data: shapeFromJsonSchema(schema), text: str }) : obj({ text: str });
|
|
1775
|
+
step = { ...base, kind: "llm", prompt: prompt ?? "", ...(system ? { system } : {}), ...(raw.tier === "fast" || raw.tier === "smart" ? { tier: raw.tier } : {}), ...(schema ? { schema } : {}) };
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
case "agent": {
|
|
1779
|
+
const instructions = this.templated(raw.instructions, `${path}.instructions`, env, true);
|
|
1780
|
+
if (raw.cap_credits !== undefined && !(typeof raw.cap_credits === "number" && raw.cap_credits > 0 && raw.cap_credits <= 100_000)) this.add(`${path}.cap_credits`, "cap_credits is a number of credits, up to 100,000");
|
|
1781
|
+
let schema: Record<string, unknown> | undefined;
|
|
1782
|
+
if (raw.schema !== undefined) {
|
|
1783
|
+
if (!isObject(raw.schema) || canonicalJson(raw.schema).length > 16_000) this.add(`${path}.schema`, "schema is a JSON Schema object, up to 16 KB");
|
|
1784
|
+
else schema = raw.schema;
|
|
1785
|
+
}
|
|
1786
|
+
shape = schema ? shapeFromJsonSchema(schema) : ANY;
|
|
1787
|
+
step = { ...base, kind: "agent", instructions: instructions ?? "", ...(typeof raw.cap_credits === "number" ? { cap_credits: raw.cap_credits } : {}), ...(schema ? { schema } : {}) };
|
|
1788
|
+
break;
|
|
1789
|
+
}
|
|
1790
|
+
case "zero": {
|
|
1791
|
+
const capability = this.text(raw.capability, `${path}.capability`, 1, 200, true);
|
|
1792
|
+
const url = this.templated(raw.url, `${path}.url`, env, true);
|
|
1793
|
+
if (url && !url.trimStart().startsWith("{{") && !/^https:\/\/[^\s/]+/i.test(url.trim())) this.add(`${path}.url`, "url is the capability's own address, starting with https://");
|
|
1794
|
+
if (raw.method !== undefined && !(ZERO_STEP_METHODS as readonly unknown[]).includes(raw.method)) this.add(`${path}.method`, `method is ${ZERO_STEP_METHODS.join(", ")}`);
|
|
1795
|
+
const input = this.value(raw.input, `${path}.input`, env, true);
|
|
1796
|
+
const pay = raw.max_pay_usd;
|
|
1797
|
+
if (!(typeof pay === "number" && Number.isFinite(pay) && pay > 0 && pay <= ZERO_STEP_MAX_PAY_USD)) this.add(`${path}.max_pay_usd`, `max_pay_usd is the most one call may pay, in USD: above 0, at most ${ZERO_STEP_MAX_PAY_USD}`);
|
|
1798
|
+
shape = obj({ ok: bool, paid_usd: numShape, cached: bool, body: ANY });
|
|
1799
|
+
step = {
|
|
1800
|
+
...base,
|
|
1801
|
+
kind: "zero",
|
|
1802
|
+
capability: capability ?? "",
|
|
1803
|
+
url: url?.trim() ?? "",
|
|
1804
|
+
...(raw.method === "GET" || raw.method === "POST" || raw.method === "PUT" || raw.method === "PATCH" ? { method: raw.method } : {}),
|
|
1805
|
+
...(input ? { input: input as { [key: string]: StepValue } } : {}),
|
|
1806
|
+
max_pay_usd: typeof pay === "number" ? pay : 0,
|
|
1807
|
+
};
|
|
1808
|
+
break;
|
|
1809
|
+
}
|
|
1810
|
+
case "automation": {
|
|
1811
|
+
const key = typeof raw.key === "string" ? raw.key : "";
|
|
1812
|
+
if (!AUTOMATION_KEY.test(key)) this.add(`${path}.key`, "key names another automation");
|
|
1813
|
+
const input = this.value(raw.input, `${path}.input`, env, true);
|
|
1814
|
+
step = { ...base, kind: "automation", key, ...(input ? { input: input as { [key: string]: StepValue } } : {}) };
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (AUTOMATION_ID.test(id)) env.steps.set(id, shape);
|
|
1819
|
+
return step;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/** The steps a loop's `collect` may read: the loop's body, in order. */
|
|
1823
|
+
private afterBody(env: Env, body: Step[]): Env {
|
|
1824
|
+
const steps = new Map(env.steps);
|
|
1825
|
+
for (const s of body) steps.set(s.id, ANY);
|
|
1826
|
+
return { ...env, steps };
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
private itemShapeOf(over: string, env: Env): OutShape {
|
|
1830
|
+
try {
|
|
1831
|
+
const e = parseSingle(over);
|
|
1832
|
+
if (e.n !== "path") return ANY;
|
|
1833
|
+
const ref: Ref[] = [];
|
|
1834
|
+
refsOfExpr(e, over, ref);
|
|
1835
|
+
const r = ref[0];
|
|
1836
|
+
if (!r || r.open || r.root !== "steps" || r.names.length < 2) return ANY;
|
|
1837
|
+
const shape = env.steps.get(r.names[0]!);
|
|
1838
|
+
if (!shape || r.names[1] !== "output") return ANY;
|
|
1839
|
+
let cur: OutShape = shape;
|
|
1840
|
+
for (const n of r.names.slice(2)) {
|
|
1841
|
+
if (cur.type === "object" && hasOwn(cur.props, n)) cur = cur.props[n]!;
|
|
1842
|
+
else return ANY;
|
|
1843
|
+
}
|
|
1844
|
+
return cur.type === "array" ? cur.items : ANY;
|
|
1845
|
+
} catch {
|
|
1846
|
+
return ANY;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** A step's expression field: one expression, checked against `env`. */
|
|
1851
|
+
private single(v: unknown, path: string, env: Env, required: boolean): string | null {
|
|
1852
|
+
if (v === undefined || v === null || v === "") {
|
|
1853
|
+
if (required) this.add(path, `${path.split(".").pop()} is required`);
|
|
1854
|
+
return null;
|
|
1855
|
+
}
|
|
1856
|
+
if (typeof v !== "string") {
|
|
1857
|
+
this.add(path, "must be an expression like {{ steps.read.output.items }}");
|
|
1858
|
+
return null;
|
|
1859
|
+
}
|
|
1860
|
+
if (v.length > AUTOMATION_LIMITS.expressionChars + 4) {
|
|
1861
|
+
this.add(path, `an expression is at most ${AUTOMATION_LIMITS.expressionChars} characters`);
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
try {
|
|
1865
|
+
const e = parseSingle(v);
|
|
1866
|
+
const refs: Ref[] = [];
|
|
1867
|
+
refsOfExpr(e, v, refs);
|
|
1868
|
+
for (const r of refs) this.checkRef(r, path, env);
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
if (error instanceof ExprError) {
|
|
1871
|
+
this.add(path, error.message);
|
|
1872
|
+
return null;
|
|
1873
|
+
}
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
return v.trim();
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** A text that may hold {{ expressions }}. */
|
|
1880
|
+
private templated(v: unknown, path: string, env: Env, required: boolean): string | null {
|
|
1881
|
+
if (v === undefined || v === null || v === "") {
|
|
1882
|
+
if (required) this.add(path, `${path.split(".").pop()} is required`);
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
if (typeof v !== "string") {
|
|
1886
|
+
this.add(path, "must be text");
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
if (v.length > AUTOMATION_LIMITS.messageChars * 4) {
|
|
1890
|
+
this.add(path, "that text is too long");
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
this.checkText(v, path, env);
|
|
1894
|
+
return v;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
private checkText(text: string, path: string, env: Env): void {
|
|
1898
|
+
try {
|
|
1899
|
+
for (const r of refsOfText(text)) this.checkRef(r, path, env);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
if (error instanceof ExprError) this.add(path, error.message);
|
|
1902
|
+
else throw error;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/** A step's value (`input`, `values`): JSON, templates checked. */
|
|
1907
|
+
private value(v: unknown, path: string, env: Env, optional: boolean): Record<string, unknown> | null {
|
|
1908
|
+
if (v === undefined) {
|
|
1909
|
+
if (!optional) this.add(path, "required");
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
if (!isObject(v)) {
|
|
1913
|
+
this.add(path, "must be an object");
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
const budget = { n: 0 };
|
|
1917
|
+
const clean = this.jsonValue(v, path, env, 0, budget);
|
|
1918
|
+
return isObject(clean) ? clean : null;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
private jsonValue(v: unknown, path: string, env: Env, depth: number, budget: { n: number }): unknown {
|
|
1922
|
+
if (++budget.n > 2000) {
|
|
1923
|
+
if (budget.n === 2001) this.add(path, "that value is too large");
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
if (depth > AUTOMATION_LIMITS.valueDepth) {
|
|
1927
|
+
this.add(path, `values nest at most ${AUTOMATION_LIMITS.valueDepth} deep`);
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
if (v === null || typeof v === "boolean") return v;
|
|
1931
|
+
if (typeof v === "number") {
|
|
1932
|
+
if (!Number.isFinite(v)) this.add(path, "numbers must be finite");
|
|
1933
|
+
return v;
|
|
1934
|
+
}
|
|
1935
|
+
if (typeof v === "string") {
|
|
1936
|
+
if (v.length > 20_000) this.add(path, "that text is too long");
|
|
1937
|
+
else this.checkText(v, path, env);
|
|
1938
|
+
return v;
|
|
1939
|
+
}
|
|
1940
|
+
if (Array.isArray(v)) return v.map((x, i) => this.jsonValue(x, `${path}[${i}]`, env, depth + 1, budget));
|
|
1941
|
+
if (isObject(v)) {
|
|
1942
|
+
const keys = Object.keys(v);
|
|
1943
|
+
if (keys.length > AUTOMATION_LIMITS.valueKeys) this.add(path, `at most ${AUTOMATION_LIMITS.valueKeys} keys in one object`);
|
|
1944
|
+
const out: Record<string, unknown> = {};
|
|
1945
|
+
for (const k of keys.slice(0, AUTOMATION_LIMITS.valueKeys)) {
|
|
1946
|
+
if (BAD_KEYS.has(k)) {
|
|
1947
|
+
this.add(`${path}.${k}`, `"${k}" cannot be a name`);
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
out[k] = this.jsonValue(v[k], `${path}.${k}`, env, depth + 1, budget);
|
|
1951
|
+
}
|
|
1952
|
+
return out;
|
|
1953
|
+
}
|
|
1954
|
+
this.add(path, "only JSON values (text, numbers, true/false, null, lists, objects)");
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** The save-time check of one reference (A8). */
|
|
1959
|
+
private checkRef(r: Ref, path: string, env: Env): void {
|
|
1960
|
+
const say = (message: string) => this.add(path, message);
|
|
1961
|
+
switch (r.root) {
|
|
1962
|
+
case "steps": {
|
|
1963
|
+
const id = r.names[0];
|
|
1964
|
+
if (id === undefined) return say('"steps" needs a step: steps.<id>.output');
|
|
1965
|
+
const shape = env.steps.get(id);
|
|
1966
|
+
if (!shape) {
|
|
1967
|
+
const known = [...env.steps.keys()];
|
|
1968
|
+
return say(`steps.${id} is not a step that comes before this one${known.length ? ` (earlier: ${known.slice(-6).join(", ")})` : ""}. A step inside another branch or loop is not readable outside it`);
|
|
1969
|
+
}
|
|
1970
|
+
if (r.names.length === 1) return say(`steps.${id} needs a field: steps.${id}.output`);
|
|
1971
|
+
if (r.names[1] !== "output") return say(`read a step's data as steps.${id}.output, not steps.${id}.${r.names[1]}`);
|
|
1972
|
+
const miss = checkShapePath(shape, r.names.slice(2));
|
|
1973
|
+
if (miss) say(`steps.${r.names.join(".")}: the output of "${id}" ${miss}`);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
case "item":
|
|
1977
|
+
case "index":
|
|
1978
|
+
if (!env.itemShape) return say(`"${r.root}" only exists inside a loop`);
|
|
1979
|
+
if (r.root === "item") {
|
|
1980
|
+
const miss = checkShapePath(env.itemShape, r.names);
|
|
1981
|
+
if (miss) say(`item ${miss}`);
|
|
1982
|
+
}
|
|
1983
|
+
return;
|
|
1984
|
+
case "inputs": {
|
|
1985
|
+
const name = r.names[0];
|
|
1986
|
+
if (name !== undefined && !hasOwn(env.inputs, name)) say(`inputs.${name} is not an input of this automation${Object.keys(env.inputs).length ? ` (it has ${Object.keys(env.inputs).join(", ")})` : " (it has none)"}`);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
case "run": {
|
|
1990
|
+
const name = r.names[0];
|
|
1991
|
+
if (name !== undefined && !["id", "started_at", "tz", "key", "attempt"].includes(name)) say(`run.${name} is not known (run.id, run.started_at, run.tz, run.key)`);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
default:
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Check a definition against every rule of the format. On success, the
|
|
2002
|
+
* normalised definition (defaults filled, only known fields kept); else
|
|
2003
|
+
* every problem, each naming where.
|
|
2004
|
+
*/
|
|
2005
|
+
export function validateAutomation(raw: unknown, opts: ValidateOptions = {}): ValidateResult {
|
|
2006
|
+
const v = new Validator(opts);
|
|
2007
|
+
let automation: AutomationDef | null = null;
|
|
2008
|
+
try {
|
|
2009
|
+
automation = v.run(raw);
|
|
2010
|
+
} catch (error) {
|
|
2011
|
+
if (error instanceof ExprError) v.add("", error.message);
|
|
2012
|
+
else throw error;
|
|
2013
|
+
}
|
|
2014
|
+
if (v.problems.length || !automation) return { ok: false, problems: v.problems.length ? v.problems : [{ path: "", message: "not a valid automation" }] };
|
|
2015
|
+
return { ok: true, automation };
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** Problems as one line each, for a refusal's text. */
|
|
2019
|
+
export function problemLines(problems: readonly Problem[], max = 8): string[] {
|
|
2020
|
+
return problems.slice(0, max).map((p) => (p.path ? `${p.path}: ${p.message}` : p.message));
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/** Every step of a list, nested ones included, with where it sits. */
|
|
2024
|
+
export function walkSteps(steps: readonly Step[], visit: (step: Step, parent: Step | null) => void, parent: Step | null = null): void {
|
|
2025
|
+
for (const s of steps) {
|
|
2026
|
+
visit(s, parent);
|
|
2027
|
+
if (s.kind === "condition") {
|
|
2028
|
+
if (s.then) walkSteps(s.then, visit, s);
|
|
2029
|
+
if (s.else) walkSteps(s.else, visit, s);
|
|
2030
|
+
} else if (s.kind === "loop") walkSteps(s.steps, visit, s);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** How many steps, nested ones included. */
|
|
2035
|
+
export function stepCount(steps: readonly Step[]): number {
|
|
2036
|
+
let n = 0;
|
|
2037
|
+
walkSteps(steps, () => n++);
|
|
2038
|
+
return n;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/* ------------------------------------------------------------------ */
|
|
2042
|
+
/* One automation starting another (phase 7) */
|
|
2043
|
+
/* ------------------------------------------------------------------ */
|
|
2044
|
+
|
|
2045
|
+
/** One automation as the cycle check sees it: its key, the automation whose
|
|
2046
|
+
* finish starts it (its `automation` trigger), and the ones its own
|
|
2047
|
+
* `automation` steps call. */
|
|
2048
|
+
export interface StartsNode {
|
|
2049
|
+
key: string;
|
|
2050
|
+
follows: string | null;
|
|
2051
|
+
calls: readonly string[];
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
export function startsNodeOf(def: Pick<AutomationDef, "key" | "trigger" | "steps">): StartsNode {
|
|
2055
|
+
const calls: string[] = [];
|
|
2056
|
+
walkSteps(def.steps, (s) => {
|
|
2057
|
+
if (s.kind === "automation" && !calls.includes(s.key)) calls.push(s.key);
|
|
2058
|
+
});
|
|
2059
|
+
return { key: def.key, follows: def.trigger.type === "automation" ? def.trigger.key : null, calls };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* A loop in "who starts whom", or null. X starts Y when Y's trigger follows X
|
|
2064
|
+
* or X's steps call Y. Returns the keys round the loop, first key repeated
|
|
2065
|
+
* last (`["a", "b", "a"]`), so the refusal can say it in words. A key no node
|
|
2066
|
+
* has is an automation that is not there (yet): not a loop.
|
|
2067
|
+
*/
|
|
2068
|
+
export function findStartCycle(nodes: readonly StartsNode[]): string[] | null {
|
|
2069
|
+
const byKey = new Map<string, StartsNode>();
|
|
2070
|
+
for (const n of nodes) byKey.set(n.key, n);
|
|
2071
|
+
const starts = new Map<string, string[]>();
|
|
2072
|
+
const add = (from: string, to: string) => {
|
|
2073
|
+
if (!byKey.has(from) || !byKey.has(to)) return;
|
|
2074
|
+
const list = starts.get(from) ?? [];
|
|
2075
|
+
if (!list.includes(to)) list.push(to);
|
|
2076
|
+
starts.set(from, list);
|
|
2077
|
+
};
|
|
2078
|
+
for (const n of nodes) {
|
|
2079
|
+
if (n.follows) add(n.follows, n.key);
|
|
2080
|
+
for (const c of n.calls) add(n.key, c);
|
|
2081
|
+
}
|
|
2082
|
+
const state = new Map<string, 1 | 2>();
|
|
2083
|
+
const stack: string[] = [];
|
|
2084
|
+
const visit = (k: string): string[] | null => {
|
|
2085
|
+
state.set(k, 1);
|
|
2086
|
+
stack.push(k);
|
|
2087
|
+
for (const next of starts.get(k) ?? []) {
|
|
2088
|
+
if (state.get(next) === 1) return [...stack.slice(stack.indexOf(next)), next];
|
|
2089
|
+
if (!state.has(next)) {
|
|
2090
|
+
const found = visit(next);
|
|
2091
|
+
if (found) return found;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
stack.pop();
|
|
2095
|
+
state.set(k, 2);
|
|
2096
|
+
return null;
|
|
2097
|
+
};
|
|
2098
|
+
for (const n of nodes) if (!state.has(n.key)) {
|
|
2099
|
+
const found = visit(n.key);
|
|
2100
|
+
if (found) return found;
|
|
2101
|
+
}
|
|
2102
|
+
return null;
|
|
2103
|
+
}
|