@wenathlan/devthink 2.0.14 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/{dist/bridge.d.ts → bridge.d.ts} +1 -1
  3. package/bridge.d.ts.map +1 -0
  4. package/bridge.js +1877 -0
  5. package/bun.d.ts.map +1 -0
  6. package/bun.js +52329 -0
  7. package/caps/background.json +712 -0
  8. package/caps/cli.json +360 -0
  9. package/caps/library.json +3611 -0
  10. package/caps/mcp.json +126 -0
  11. package/caps/pagebridge.json +377 -0
  12. package/caps/popup.json +51 -0
  13. package/caps/sidepanel.json +522 -0
  14. package/checksums.txt +142 -0
  15. package/cli.d.ts.map +1 -0
  16. package/{dist/cli.js → cli.js} +2 -2
  17. package/companion.d.ts.map +1 -0
  18. package/companion.js +389 -0
  19. package/crossbrowser.d.ts.map +1 -0
  20. package/crossbrowser.js +1191 -0
  21. package/dashdone.d.ts.map +1 -0
  22. package/dashdone.js +368 -0
  23. package/deno.d.ts.map +1 -0
  24. package/deno.js +52325 -0
  25. package/devthink.umd.js +52346 -0
  26. package/fixtures/bun.ts +7 -0
  27. package/fixtures/cjs.js +6 -0
  28. package/fixtures/deno.ts +6 -0
  29. package/fixtures/example-org-pagestate.json +22 -0
  30. package/fixtures/importers/automa-workflow.json +18 -0
  31. package/fixtures/importers/selenium-side.json +20 -0
  32. package/fixtures/importers/tabular-plan.csv +7 -0
  33. package/fixtures/importers/uivision-macro.json +10 -0
  34. package/fixtures/importers/v1-plan.json +13 -0
  35. package/fixtures/mcp-client.mjs +14 -0
  36. package/fixtures/pages/checkout.html +192 -0
  37. package/fixtures/pages/dashboard.html +110 -0
  38. package/fixtures/pages/feed.html +113 -0
  39. package/fixtures/pages/product-grid.html +150 -0
  40. package/fixtures/plans/form-inventory-plan.json +11 -0
  41. package/fixtures/plans/release-notes-plan.json +11 -0
  42. package/fixtures/recipes/agentscompete.json +11 -0
  43. package/fixtures/recipes/agentsforms.json +11 -0
  44. package/fixtures/recipes/agentsmonitor.json +11 -0
  45. package/fixtures/recipes/agentsreview.json +29 -0
  46. package/fixtures/recipes/agentswarm.json +11 -0
  47. package/fixtures/recipes/fillcalendar.json +12 -0
  48. package/fixtures/recipes/fillcheckout.json +13 -0
  49. package/fixtures/recipes/filldependentdropdowns.json +12 -0
  50. package/fixtures/recipes/fillfromcsv.json +13 -0
  51. package/fixtures/recipes/fillloginform.json +12 -0
  52. package/fixtures/recipes/fillmultistepwizard.json +13 -0
  53. package/fixtures/recipes/fillregistration.json +12 -0
  54. package/fixtures/recipes/fillsearchfilters.json +12 -0
  55. package/fixtures/recipes/filltypeahead.json +11 -0
  56. package/fixtures/recipes/monitoralerts.json +12 -0
  57. package/fixtures/recipes/monitoravailability.json +11 -0
  58. package/fixtures/recipes/monitornetworkquiet.json +12 -0
  59. package/fixtures/recipes/monitorpagechanges.json +11 -0
  60. package/fixtures/recipes/monitorpricechange.json +11 -0
  61. package/fixtures/recipes/monitorschedule.json +12 -0
  62. package/fixtures/recipes/scrapedynamicprices.json +13 -0
  63. package/fixtures/recipes/scrapeimagegallery.json +12 -0
  64. package/fixtures/recipes/scrapeinfinitefeed.json +13 -0
  65. package/fixtures/recipes/scrapenestedlists.json +12 -0
  66. package/fixtures/recipes/scrapeproductgrid.json +12 -0
  67. package/fixtures/recipes/scrapereviews.json +12 -0
  68. package/fixtures/recipes/scrapesearchresults.json +12 -0
  69. package/fixtures/recipes/scrapetablepagination.json +12 -0
  70. package/fixtures/recipes/testaccessibility.json +34 -0
  71. package/fixtures/recipes/testconsoleerrors.json +12 -0
  72. package/fixtures/recipes/testdeeplinks.json +12 -0
  73. package/fixtures/recipes/testformvalidation.json +13 -0
  74. package/fixtures/recipes/testlayoutshift.json +12 -0
  75. package/fixtures/recipes/testlinkcheck.json +12 -0
  76. package/fixtures/recipes/testloadtiming.json +12 -0
  77. package/fixtures/recipes/testvisualdiff.json +12 -0
  78. package/fixtures/workflows/changelog-digest-workflow.json +13 -0
  79. package/fixtures/workflows/form-survey-workflow.json +13 -0
  80. package/gallery.json +706 -0
  81. package/hardening.d.ts.map +1 -0
  82. package/hardening.js +188 -0
  83. package/headless.d.ts.map +1 -0
  84. package/headless.js +11522 -0
  85. package/http.d.ts.map +1 -0
  86. package/http.js +2165 -0
  87. package/index.cjs +54682 -0
  88. package/index.d.ts.map +1 -0
  89. package/index.js +52310 -0
  90. package/index.neutral.js +46343 -0
  91. package/{web/manifest.json → manifest.json} +1 -1
  92. package/mcp.d.ts.map +1 -0
  93. package/mcp.js +20515 -0
  94. package/memory.d.ts.map +1 -0
  95. package/{memory.ts → memory.js} +2310 -3834
  96. package/nativehost.template.json +10 -0
  97. package/node.cjs +54686 -0
  98. package/node.d.ts.map +1 -0
  99. package/pack.d.ts.map +1 -0
  100. package/pack.js +805 -0
  101. package/package.json +83 -176
  102. package/policy.d.ts.map +1 -0
  103. package/{policy.ts → policy.js} +4679 -5073
  104. package/progress.d.ts.map +1 -0
  105. package/progress.js +645 -0
  106. package/protocol.d.ts.map +1 -0
  107. package/protocol.js +8334 -0
  108. package/schemas/audit.schema.json +226 -0
  109. package/schemas/capability.schema.json +113 -0
  110. package/schemas/envelope.schema.json +162 -0
  111. package/schemas/memory.schema.json +51 -0
  112. package/schemas/observation.schema.json +113 -0
  113. package/schemas/plan.schema.json +208 -0
  114. package/schemas/progress.schema.json +126 -0
  115. package/schemas/proposal.schema.json +68 -0
  116. package/schemas/session.schema.json +82 -0
  117. package/schemas/tool.schema.json +94 -0
  118. package/server.d.ts.map +1 -0
  119. package/server.js +28877 -0
  120. package/umd-example.html +18 -0
  121. package/umd.d.ts.map +1 -0
  122. package/accounts.ts +0 -2087
  123. package/agent.ts +0 -2331
  124. package/antigravity.ts +0 -2139
  125. package/apifreeze.ts +0 -4146
  126. package/auth.ts +0 -642
  127. package/background.ts +0 -16837
  128. package/bridge.ts +0 -2080
  129. package/build.ts +0 -54
  130. package/bun.ts +0 -26
  131. package/capture.ts +0 -1614
  132. package/checksecrets.ts +0 -72
  133. package/cjs.ts +0 -9
  134. package/cli.ts +0 -3500
  135. package/commands.ts +0 -1101
  136. package/companion.ts +0 -450
  137. package/compatibility.ts +0 -37
  138. package/config.ts +0 -3137
  139. package/constants.ts +0 -943
  140. package/core.ts +0 -1473
  141. package/crossbrowser.ts +0 -1359
  142. package/dashdone.ts +0 -605
  143. package/data.ts +0 -943
  144. package/database.ts +0 -133
  145. package/debug.ts +0 -850
  146. package/deno.ts +0 -27
  147. package/devthink.ts +0 -707
  148. package/dist/accounts.d.ts +0 -409
  149. package/dist/agent.d.ts +0 -837
  150. package/dist/antigravity.d.ts +0 -380
  151. package/dist/apifreeze.d.ts +0 -113
  152. package/dist/auth.d.ts +0 -224
  153. package/dist/capture.d.ts +0 -595
  154. package/dist/cjs.d.ts +0 -6
  155. package/dist/commands.d.ts +0 -310
  156. package/dist/compatibility.d.ts +0 -17
  157. package/dist/config.d.ts +0 -1853
  158. package/dist/constants.d.ts +0 -637
  159. package/dist/core.d.ts +0 -361
  160. package/dist/data.d.ts +0 -330
  161. package/dist/database.d.ts +0 -24
  162. package/dist/debug.d.ts +0 -270
  163. package/dist/engine.d.ts +0 -74
  164. package/dist/environments.d.ts +0 -244
  165. package/dist/evidence.d.ts +0 -277
  166. package/dist/export.d.ts +0 -311
  167. package/dist/fingerprint.d.ts +0 -594
  168. package/dist/flow.d.ts +0 -300
  169. package/dist/gates.d.ts +0 -103
  170. package/dist/identity.d.ts +0 -51
  171. package/dist/ids.d.ts +0 -7
  172. package/dist/llm.d.ts +0 -652
  173. package/dist/models.d.ts +0 -1807
  174. package/dist/net.d.ts +0 -325
  175. package/dist/neutral.d.ts +0 -41
  176. package/dist/oauth.d.ts +0 -703
  177. package/dist/page.d.ts +0 -1166
  178. package/dist/perf.d.ts +0 -481
  179. package/dist/plan.d.ts +0 -134
  180. package/dist/project.d.ts +0 -395
  181. package/dist/providers.d.ts +0 -37
  182. package/dist/quota.d.ts +0 -453
  183. package/dist/recovery.d.ts +0 -621
  184. package/dist/request.d.ts +0 -1492
  185. package/dist/run.d.ts +0 -779
  186. package/dist/runtime.d.ts +0 -212
  187. package/dist/search.d.ts +0 -375
  188. package/dist/security.d.ts +0 -534
  189. package/dist/serve.d.ts +0 -136
  190. package/dist/session.d.ts +0 -296
  191. package/dist/storage.d.ts +0 -53
  192. package/dist/streaming.d.ts +0 -1119
  193. package/dist/swarm.d.ts +0 -328
  194. package/dist/tools.d.ts +0 -170
  195. package/dist/types.d.ts +0 -8357
  196. package/dist/utils.d.ts +0 -82
  197. package/dist/version.d.ts +0 -7
  198. package/dist/versionregistry.d.ts +0 -406
  199. package/dist/views.d.ts +0 -579
  200. package/dist/workbenchsession.d.ts +0 -69
  201. package/dist/workflow.d.ts +0 -649
  202. package/docs/readme.md +0 -92
  203. package/docs/schemas/antigravity.json +0 -192
  204. package/engine.ts +0 -1524
  205. package/environments.ts +0 -1005
  206. package/evidence.ts +0 -643
  207. package/export.ts +0 -889
  208. package/fingerprint.ts +0 -1458
  209. package/flow.ts +0 -1190
  210. package/gates.ts +0 -307
  211. package/gateway.ts +0 -34
  212. package/hardening.ts +0 -191
  213. package/headless.ts +0 -369
  214. package/http.ts +0 -2110
  215. package/identity.ts +0 -246
  216. package/ids.ts +0 -26
  217. package/index.ts +0 -369
  218. package/llm.ts +0 -2682
  219. package/mcp.ts +0 -1835
  220. package/models.ts +0 -3805
  221. package/modes.ts +0 -43
  222. package/net.ts +0 -1214
  223. package/neutral.ts +0 -362
  224. package/node.ts +0 -26
  225. package/oauth.ts +0 -2216
  226. package/offscreen.ts +0 -170
  227. package/pack.ts +0 -956
  228. package/page.ts +0 -7824
  229. package/pagebridge.ts +0 -1116
  230. package/perf.ts +0 -957
  231. package/plan.ts +0 -672
  232. package/plugin.ts +0 -76
  233. package/prisma.config.ts +0 -27
  234. package/progress.ts +0 -1308
  235. package/project.ts +0 -1291
  236. package/protocol.ts +0 -4187
  237. package/providers.ts +0 -246
  238. package/quota.ts +0 -1887
  239. package/recovery.ts +0 -2433
  240. package/request.ts +0 -4482
  241. package/run.ts +0 -2135
  242. package/runtime.ts +0 -526
  243. package/search.ts +0 -1760
  244. package/security.ts +0 -1673
  245. package/serve.ts +0 -419
  246. package/server.ts +0 -5538
  247. package/session.ts +0 -1162
  248. package/storage.ts +0 -182
  249. package/streaming.ts +0 -4101
  250. package/swarm.ts +0 -884
  251. package/sync.ts +0 -56
  252. package/tests/agentcert.test.ts +0 -128
  253. package/tests/agentfleet.test.ts +0 -214
  254. package/tests/agentreview.test.ts +0 -241
  255. package/tests/agentstream.test.ts +0 -335
  256. package/tests/agentwork.test.ts +0 -356
  257. package/tests/apifreeze.test.ts +0 -290
  258. package/tests/apimap.test.ts +0 -112
  259. package/tests/approvalgate.test.ts +0 -175
  260. package/tests/artifactmanifest.test.ts +0 -204
  261. package/tests/attentionfeed.test.ts +0 -163
  262. package/tests/backgroundruns.test.ts +0 -100
  263. package/tests/batchscheduling.test.ts +0 -131
  264. package/tests/blackboard.test.ts +0 -238
  265. package/tests/bridge.test.ts +0 -1088
  266. package/tests/browsercoverage.test.ts +0 -134
  267. package/tests/browserpolyfills.test.ts +0 -124
  268. package/tests/build.mjs +0 -1260
  269. package/tests/clientauth.test.ts +0 -223
  270. package/tests/clitools.test.ts +0 -578
  271. package/tests/confirmgates.test.ts +0 -190
  272. package/tests/containerpack.test.ts +0 -143
  273. package/tests/controlflow.test.ts +0 -2120
  274. package/tests/coordination.test.ts +0 -345
  275. package/tests/copyscreen.test.ts +0 -139
  276. package/tests/core.test.ts +0 -417
  277. package/tests/costcert.test.ts +0 -95
  278. package/tests/crossbrowser.test.ts +0 -115
  279. package/tests/cspaudit.test.ts +0 -79
  280. package/tests/dashdone.test.ts +0 -615
  281. package/tests/datagrid.test.ts +0 -137
  282. package/tests/doccheck.test.ts +0 -107
  283. package/tests/dombatch.test.ts +0 -109
  284. package/tests/emulation.test.ts +0 -282
  285. package/tests/environments.test.ts +0 -259
  286. package/tests/evidenceviews.test.ts +0 -88
  287. package/tests/exporttools.test.ts +0 -185
  288. package/tests/firefoxprep.test.ts +0 -134
  289. package/tests/flowlibrary.test.ts +0 -242
  290. package/tests/flowrun.test.ts +0 -303
  291. package/tests/forensics.test.ts +0 -439
  292. package/tests/gateway.test.ts +0 -939
  293. package/tests/headlesslib.test.ts +0 -171
  294. package/tests/httpstream.test.ts +0 -277
  295. package/tests/immutablelog.test.ts +0 -157
  296. package/tests/inboundguard.test.ts +0 -177
  297. package/tests/lazyload.test.ts +0 -72
  298. package/tests/librarymodes.test.ts +0 -623
  299. package/tests/llm.test.ts +0 -696
  300. package/tests/manifest.test.ts +0 -226
  301. package/tests/maskinputs.test.ts +0 -150
  302. package/tests/matrixverify.test.ts +0 -111
  303. package/tests/mavenpack.test.ts +0 -75
  304. package/tests/mcpserver.test.ts +0 -704
  305. package/tests/memory.test.ts +0 -4155
  306. package/tests/memorycare.test.ts +0 -230
  307. package/tests/migrateplan.test.ts +0 -349
  308. package/tests/minimization.test.ts +0 -414
  309. package/tests/modelroute.test.ts +0 -112
  310. package/tests/multiagent.test.ts +0 -440
  311. package/tests/nativebridge.test.ts +0 -807
  312. package/tests/navigation.test.ts +0 -284
  313. package/tests/nugetpack.test.ts +0 -83
  314. package/tests/orchestration.test.ts +0 -449
  315. package/tests/originpolicy.test.ts +0 -405
  316. package/tests/outputcompare.test.ts +0 -97
  317. package/tests/package.test.ts +0 -91
  318. package/tests/pagebridge.test.ts +0 -5909
  319. package/tests/pentest.test.ts +0 -75
  320. package/tests/perfrecords.test.ts +0 -257
  321. package/tests/permdiff.test.ts +0 -85
  322. package/tests/phishguard.test.ts +0 -128
  323. package/tests/pickerviews.test.ts +0 -155
  324. package/tests/pipelines.test.ts +0 -381
  325. package/tests/planlint.test.ts +0 -240
  326. package/tests/planreview.test.ts +0 -383
  327. package/tests/platformtargets.test.ts +0 -56
  328. package/tests/policy.test.ts +0 -8284
  329. package/tests/portability.test.ts +0 -96
  330. package/tests/progress.test.ts +0 -1036
  331. package/tests/promptlibrary.test.ts +0 -116
  332. package/tests/protocol.test.ts +0 -4793
  333. package/tests/publishingpipeline.test.ts +0 -314
  334. package/tests/quickactions.test.ts +0 -164
  335. package/tests/readiness.test.ts +0 -78
  336. package/tests/recipes.test.ts +0 -163
  337. package/tests/redactshots.test.ts +0 -216
  338. package/tests/registernvidiakeys.ts +0 -51
  339. package/tests/relayserve.test.ts +0 -304
  340. package/tests/releasenotes.test.ts +0 -276
  341. package/tests/resilience.test.ts +0 -467
  342. package/tests/resourceaware.test.ts +0 -187
  343. package/tests/runbudget.test.ts +0 -125
  344. package/tests/runhistory.test.ts +0 -299
  345. package/tests/runreplay.test.ts +0 -113
  346. package/tests/runresume.test.ts +0 -184
  347. package/tests/runstate.test.ts +0 -288
  348. package/tests/runtimeadapters.test.ts +0 -251
  349. package/tests/safariskeleton.test.ts +0 -115
  350. package/tests/sandboxframe.test.ts +0 -196
  351. package/tests/sbom.test.ts +0 -105
  352. package/tests/secretvault.test.ts +0 -188
  353. package/tests/selcache.test.ts +0 -111
  354. package/tests/sessioninterface.test.ts +0 -791
  355. package/tests/sessions.test.ts +0 -591
  356. package/tests/siteprefs.test.ts +0 -228
  357. package/tests/snapshotdelta.test.ts +0 -165
  358. package/tests/statusviews.test.ts +0 -237
  359. package/tests/stepmeter.test.ts +0 -200
  360. package/tests/streamparse.test.ts +0 -163
  361. package/tests/stylepruning.test.ts +0 -90
  362. package/tests/surfaces.test.ts +0 -282
  363. package/tests/sweep.test.ts +0 -78
  364. package/tests/syncbridge.test.ts +0 -164
  365. package/tests/taskqueue.test.ts +0 -206
  366. package/tests/toolcalls.test.ts +0 -336
  367. package/tests/toolcatalog.test.ts +0 -201
  368. package/tests/torture-agent.test.ts +0 -706
  369. package/tests/torture-auth.test.ts +0 -461
  370. package/tests/torture-commands.test.ts +0 -895
  371. package/tests/torture-data.test.ts +0 -923
  372. package/tests/torture-environments.test.ts +0 -285
  373. package/tests/torture-export.test.ts +0 -263
  374. package/tests/torture-http.test.ts +0 -1379
  375. package/tests/torture-llm.test.ts +0 -808
  376. package/tests/torture-memory.test.ts +0 -1084
  377. package/tests/torture-plan.test.ts +0 -829
  378. package/tests/torture-run.test.ts +0 -626
  379. package/tests/torture-session.test.ts +0 -928
  380. package/tests/torture-swarm.test.ts +0 -1245
  381. package/tests/torture-tools.test.ts +0 -964
  382. package/tests/torture-workflow.test.ts +0 -2354
  383. package/tests/tourviews.test.ts +0 -52
  384. package/tests/transparency.test.ts +0 -147
  385. package/tests/trigger.test.ts +0 -941
  386. package/tests/v1sunset.test.ts +0 -428
  387. package/tests/virtlist.test.ts +0 -94
  388. package/tests/vision.test.ts +0 -540
  389. package/tests/vsixpack.test.ts +0 -145
  390. package/tests/webapi.test.ts +0 -523
  391. package/tests/workflow.test.ts +0 -1785
  392. package/tests/workfloweditor.test.ts +0 -759
  393. package/tests/xpipack.test.ts +0 -128
  394. package/tools.ts +0 -734
  395. package/types.ts +0 -9908
  396. package/umd.ts +0 -9
  397. package/utils.ts +0 -249
  398. package/version.ts +0 -8
  399. package/versionregistry.ts +0 -1310
  400. package/views.ts +0 -2153
  401. package/vitest.config.ts +0 -12
  402. package/web/App.tsx +0 -78
  403. package/web/README.md +0 -47
  404. package/web/capacitor.config.ts +0 -46
  405. package/web/const.ts +0 -19
  406. package/web/control.shell.tsx +0 -66
  407. package/web/dashboardpage.ts +0 -554
  408. package/web/db.ts +0 -493
  409. package/web/design.html +0 -541
  410. package/web/gatewayview/Gateway.tsx +0 -345
  411. package/web/gatewayview/config.ts +0 -1042
  412. package/web/gatewayview/definition.ts +0 -174
  413. package/web/icons.ts +0 -15
  414. package/web/optionspage.ts +0 -691
  415. package/web/popup.ts +0 -1651
  416. package/web/schema.prisma +0 -176
  417. package/web/sidepanel.ts +0 -7941
  418. package/web/sitemanifest.json +0 -13
  419. package/web/terminal-ui.tsx +0 -622
  420. package/web/transparencypage.ts +0 -541
  421. package/web/vite.config.ts +0 -39
  422. package/web/workspace.ts +0 -23
  423. package/workbenchmemory.ts +0 -104
  424. package/workbenchsession.ts +0 -304
  425. package/workflow.ts +0 -4495
  426. /package/{dist/bun.d.ts → bun.d.ts} +0 -0
  427. /package/{dist/cli.d.ts → cli.d.ts} +0 -0
  428. /package/{dist/companion.d.ts → companion.d.ts} +0 -0
  429. /package/{dist/crossbrowser.d.ts → crossbrowser.d.ts} +0 -0
  430. /package/{dist/dashdone.d.ts → dashdone.d.ts} +0 -0
  431. /package/{dist/deno.d.ts → deno.d.ts} +0 -0
  432. /package/{dist/hardening.d.ts → hardening.d.ts} +0 -0
  433. /package/{dist/headless.d.ts → headless.d.ts} +0 -0
  434. /package/{dist/http.d.ts → http.d.ts} +0 -0
  435. /package/{dist/index.d.ts → index.d.ts} +0 -0
  436. /package/{dist/mcp.d.ts → mcp.d.ts} +0 -0
  437. /package/{dist/memory.d.ts → memory.d.ts} +0 -0
  438. /package/{dist/node.d.ts → node.d.ts} +0 -0
  439. /package/{dist/pack.d.ts → pack.d.ts} +0 -0
  440. /package/{dist/policy.d.ts → policy.d.ts} +0 -0
  441. /package/{dist/progress.d.ts → progress.d.ts} +0 -0
  442. /package/{dist/protocol.d.ts → protocol.d.ts} +0 -0
  443. /package/{dist/server.d.ts → server.d.ts} +0 -0
  444. /package/{dist/umd.d.ts → umd.d.ts} +0 -0
package/workflow.ts DELETED
@@ -1,4495 +0,0 @@
1
- /**
2
- * The workflow module of the 1.1.90 consolidation: every correlated variation of the workflow engine, its control constructs, its triggers and its editor interned in this one file, so the module family carries one surface without duplicate variations — the workflow and controlflow modules import each other today, and the merge resolves that circular import by making the engine base and its control constructs one module.
3
- * The correlation is the workflow family: workflow holds the engine base (the step, block and template normalizers, the composition that validates and freezes a step list, the block expansion that hides no step from review, the typed scope stack with shadowing, the variable bindings, the expression arithmetic with coercion refusals, the regex extraction, the seeded delay jitter, the run loop with per step checkpoints, the pause, resume and cancel transitions and the pure dry run); controlflow holds the control constructs the engine executes (the condition, branch, loop, repeatuntil, while, foreach, parallel and trycatch steps with their bounds, the retry policy with its backoff, the timeout aborts and the seeded control random); trigger holds the rule kinds that arm, schedule, match and fire the reviewed workflows (the cooldowns, the cron and calendar parts, the webhook verification and the manual run confirmation); and workfloweditor holds the editor surface (the palette and step library, the canvas layout with snap, reorder and group select, the undo and redo history, the minimap, the breakpoints, the version diff, the import and export in the reviewed file format and the site overrides).
4
- * No loop bound, retry policy, cooldown or cron field is ever hardcoded beyond the documented grammar: every bound stays the user's choice, and no workflow, trigger or editor path ever bypasses the human review.
5
- */
6
-
7
- import type {
8
- actionrisk,
9
- blockinvocation,
10
- delaystep,
11
- expressiontype,
12
- nestedparam,
13
- regexrule,
14
- runlogentry,
15
- steptemplate,
16
- variablebinding,
17
- variablekind,
18
- variablescope,
19
- variablevalue,
20
- watchdogconfig,
21
- workflowblock,
22
- workflowrecord,
23
- workflowrun,
24
- workflowstep,
25
- stepoutcome,
26
- branchoutcome,
27
- branchpath,
28
- branchstep,
29
- conditionstep,
30
- controlflowdecision,
31
- errorhandler,
32
- foreachstep,
33
- joinrecord,
34
- loopcounter,
35
- loopstep,
36
- paralleloutcome,
37
- parallelstep,
38
- repeatuntilstep,
39
- retrypolicy,
40
- retryattempt,
41
- timeoutabort,
42
- trystep,
43
- whilestep,
44
- manualrun,
45
- rulestats,
46
- triggerfamily,
47
- triggerfire,
48
- triggerstate,
49
- triggerule,
50
- webhookfield,
51
- editoredge,
52
- editormodel,
53
- editornode,
54
- editorlayout,
55
- exportformat,
56
- minimapstate,
57
- palettecategory,
58
- palettenode,
59
- siteoverride,
60
- steplibraryentry,
61
- versiondiff,
62
- workflowfile,
63
- } from "./types.js";
64
-
65
- /**
66
- * Workflow engine for the 1.1.50 family.
67
- * Every correlated rule for the reviewed step composition lives in this file: the workflow kind list, the step, block and template normalizers, the composition that validates and freezes a step list, the block expansion that hides no step from review, the pre-run validation of kinds, scopes and bindings, the typed scope stack with shadowing, the variable bindings that link step outputs to names, the expression arithmetic with coercion refusals, the regex extraction with honest no match outcomes, the seeded delay jitter, the run loop with per step checkpoints, the single step execution, the pause, resume and cancel transitions and the pure dry run with read only projections.
68
- * The engine stays pure: every page, browser and storage effect flows through the injected executor so tests run on plain fixtures, and the risk grading flows through the injected risk callback so the policy table stays the single source of truth.
69
- */
70
-
71
- /** The workflow kinds of the 1.1.50 family: composition, templates, runs, dry runs, jittered delays, element waits, expressions and variable extraction. */
72
- export const workflowkinds: string[] = [
73
- "composeworkflow",
74
- "savetemplate",
75
- "runworkflow",
76
- "dryrun",
77
- "delay",
78
- "waitelement",
79
- "compute",
80
- "extractvars",
81
- ];
82
-
83
- /** The outcome the injected executor returns for one workflow step: control flow executors also return the merged scopes and the iteration runlog entries so the run loop adopts them. */
84
- export type stepexecution = {
85
- ok: boolean;
86
- summary: string;
87
- details?: Record<string, unknown>;
88
- scopes?: variablescope[];
89
- log?: runlogentry[];
90
- };
91
-
92
- /** Normalizes one nested parameter of a block invocation: the variable name, the reviewed kind and the optional default value. */
93
- function nestedparamof(value: unknown): nestedparam | undefined {
94
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
95
- const candidate = value as Record<string, unknown>;
96
- if (typeof candidate.name !== "string" || !/^[a-z][a-z0-9]*$/.test(candidate.name)) return undefined;
97
- if (!variablekinds.includes(candidate.kind as variablekind)) return undefined;
98
- if (
99
- candidate.default !== undefined &&
100
- !["string", "number", "boolean"].includes(typeof candidate.default) &&
101
- !Array.isArray(candidate.default)
102
- )
103
- return undefined;
104
- return {
105
- name: candidate.name,
106
- kind: candidate.kind as variablekind,
107
- ...(candidate.default !== undefined ? { default: candidate.default as string | number | boolean | string[] } : {}),
108
- };
109
- }
110
-
111
- /** Normalizes one workflow step: id, kind, label, the optional target, value and JSON options, the output bindings, the inline expression, the inline regex rule, the breakpoint marker and the nested block parameters. */
112
- export function workflowstepof(value: unknown): workflowstep | undefined {
113
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
114
- const candidate = value as Record<string, unknown>;
115
- if (typeof candidate.id !== "string" || !candidate.id.trim()) return undefined;
116
- if (typeof candidate.kind !== "string" || !/^[a-z]+$/.test(candidate.kind)) return undefined;
117
- if (typeof candidate.label !== "string" || !candidate.label.trim()) return undefined;
118
- if (candidate.target !== undefined && (typeof candidate.target !== "string" || !candidate.target)) return undefined;
119
- if (candidate.value !== undefined && typeof candidate.value !== "string") return undefined;
120
- if (candidate.options !== undefined && typeof candidate.options !== "string") return undefined;
121
- if (candidate.breakpoint !== undefined && typeof candidate.breakpoint !== "boolean") return undefined;
122
- const bindings = Array.isArray(candidate.bindings)
123
- ? candidate.bindings.flatMap((binding) =>
124
- bindingof(binding) !== undefined ? [bindingof(binding) as variablebinding] : [],
125
- )
126
- : undefined;
127
- if (candidate.bindings !== undefined && bindings === undefined) return undefined;
128
- if (
129
- Array.isArray(candidate.bindings) &&
130
- bindings !== undefined &&
131
- bindings.length !== (candidate.bindings as unknown[]).length
132
- )
133
- return undefined;
134
- const expression = candidate.expression === undefined ? undefined : expressionof(candidate.expression);
135
- if (candidate.expression !== undefined && expression === undefined) return undefined;
136
- const extract = candidate.extract === undefined ? undefined : regexruleof(candidate.extract);
137
- if (candidate.extract !== undefined && extract === undefined) return undefined;
138
- const params = Array.isArray(candidate.params)
139
- ? candidate.params.flatMap((param) =>
140
- nestedparamof(param) !== undefined ? [nestedparamof(param) as nestedparam] : [],
141
- )
142
- : undefined;
143
- if (candidate.params !== undefined && params === undefined) return undefined;
144
- if (
145
- Array.isArray(candidate.params) &&
146
- params !== undefined &&
147
- params.length !== (candidate.params as unknown[]).length
148
- )
149
- return undefined;
150
- return {
151
- id: candidate.id,
152
- kind: candidate.kind as workflowstep["kind"],
153
- label: candidate.label,
154
- ...(candidate.target !== undefined ? { target: candidate.target } : {}),
155
- ...(candidate.value !== undefined ? { value: candidate.value } : {}),
156
- ...(candidate.options !== undefined ? { options: candidate.options } : {}),
157
- ...(bindings !== undefined && bindings.length > 0 ? { bindings } : {}),
158
- ...(expression !== undefined ? { expression } : {}),
159
- ...(extract !== undefined ? { extract } : {}),
160
- ...(candidate.breakpoint === true ? { breakpoint: true } : {}),
161
- ...(params !== undefined && params.length > 0 ? { params } : {}),
162
- };
163
- }
164
-
165
- /** Normalizes one block invocation: the referenced block name and the human readable label. */
166
- export function blockinvocationof(value: unknown): blockinvocation | undefined {
167
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
168
- const candidate = value as Record<string, unknown>;
169
- if (typeof candidate.block !== "string" || !candidate.block.trim()) return undefined;
170
- if (typeof candidate.label !== "string" || !candidate.label.trim()) return undefined;
171
- const params = Array.isArray(candidate.params)
172
- ? candidate.params.flatMap((param) =>
173
- nestedparamof(param) !== undefined ? [nestedparamof(param) as nestedparam] : [],
174
- )
175
- : undefined;
176
- if (candidate.params !== undefined && params === undefined) return undefined;
177
- if (
178
- Array.isArray(candidate.params) &&
179
- params !== undefined &&
180
- params.length !== (candidate.params as unknown[]).length
181
- )
182
- return undefined;
183
- return {
184
- block: candidate.block,
185
- label: candidate.label,
186
- ...(params !== undefined && params.length > 0 ? { params } : {}),
187
- };
188
- }
189
-
190
- /** Normalizes one reusable workflow block: the unique name, the label and the child steps with nested block invocations. */
191
- export function workflowblockof(value: unknown): workflowblock | undefined {
192
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
193
- const candidate = value as Record<string, unknown>;
194
- if (typeof candidate.name !== "string" || !/^[a-z][a-z0-9]*$/.test(candidate.name)) return undefined;
195
- if (typeof candidate.label !== "string" || !candidate.label.trim()) return undefined;
196
- if (!Array.isArray(candidate.steps)) return undefined;
197
- const steps: Array<workflowstep | blockinvocation> = [];
198
- for (const entry of candidate.steps) {
199
- const step = workflowstepof(entry);
200
- if (step) {
201
- steps.push(step);
202
- continue;
203
- }
204
- const invocation = blockinvocationof(entry);
205
- if (invocation) {
206
- steps.push(invocation);
207
- continue;
208
- }
209
- return undefined;
210
- }
211
- return { name: candidate.name, label: candidate.label, steps };
212
- }
213
-
214
- /** Normalizes one shareable step template: the id, the unique name, the origin, the step and the share time. */
215
- export function steptemplateof(value: unknown): steptemplate | undefined {
216
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
217
- const candidate = value as Record<string, unknown>;
218
- if (typeof candidate.id !== "string" || !candidate.id.trim()) return undefined;
219
- if (typeof candidate.name !== "string" || !candidate.name.trim()) return undefined;
220
- if (typeof candidate.origin !== "string" || !candidate.origin.trim()) return undefined;
221
- const step = workflowstepof(candidate.step);
222
- if (!step) return undefined;
223
- if (typeof candidate.sharedat !== "number" || !Number.isFinite(candidate.sharedat)) return undefined;
224
- return { id: candidate.id, name: candidate.name, origin: candidate.origin, step, sharedat: candidate.sharedat };
225
- }
226
-
227
- /** Normalizes one variable binding that links a step output path to a named variable of a typed kind. */
228
- function bindingof(value: unknown): variablebinding | undefined {
229
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
230
- const candidate = value as Record<string, unknown>;
231
- if (typeof candidate.variable !== "string" || !/^[a-z][a-z0-9]*$/.test(candidate.variable)) return undefined;
232
- if (!variablekinds.includes(candidate.kind as variablekind)) return undefined;
233
- if (typeof candidate.stepid !== "string" || !candidate.stepid.trim()) return undefined;
234
- if (candidate.path !== undefined && (typeof candidate.path !== "string" || !candidate.path.trim())) return undefined;
235
- return {
236
- variable: candidate.variable,
237
- kind: candidate.kind as variablekind,
238
- stepid: candidate.stepid,
239
- ...(candidate.path !== undefined ? { path: candidate.path } : {}),
240
- };
241
- }
242
-
243
- /** The typed variable kinds of the scope grammar. */
244
- const variablekinds: variablekind[] = ["string", "number", "boolean", "list", "element"];
245
-
246
- /** The reviewed expression operators of the workflow grammar. */
247
- export const expressionoperators: string[] = [
248
- "add",
249
- "subtract",
250
- "multiply",
251
- "divide",
252
- "modulo",
253
- "equal",
254
- "notequal",
255
- "less",
256
- "greater",
257
- "lessequal",
258
- "greaterequal",
259
- "and",
260
- "or",
261
- "not",
262
- "concat",
263
- "contains",
264
- "length",
265
- ];
266
-
267
- /** Normalizes one reviewed expression: the operands, the operator and the result variable with its result kind. */
268
- export function expressionof(value: unknown): expressiontype | undefined {
269
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
270
- const candidate = value as Record<string, unknown>;
271
- const left = operandof(candidate.left);
272
- if (!left) return undefined;
273
- const right = candidate.right === undefined ? undefined : operandof(candidate.right);
274
- if (candidate.right !== undefined && right === undefined) return undefined;
275
- if (typeof candidate.operator !== "string" || !expressionoperators.includes(candidate.operator)) return undefined;
276
- if (typeof candidate.result !== "string" || !/^[a-z][a-z0-9]*$/.test(candidate.result)) return undefined;
277
- if (!variablekinds.includes(candidate.resultkind as variablekind)) return undefined;
278
- return {
279
- left,
280
- ...(right !== undefined ? { right } : {}),
281
- operator: candidate.operator as expressiontype["operator"],
282
- result: candidate.result,
283
- resultkind: candidate.resultkind as variablekind,
284
- };
285
- }
286
-
287
- /** Normalizes one expression operand: a variable reference or a literal of a reviewed primitive kind. */
288
- function operandof(value: unknown): { ref?: string; literal?: string | number | boolean } | undefined {
289
- if (value === undefined) return undefined;
290
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return { literal: value };
291
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
292
- const candidate = value as Record<string, unknown>;
293
- if (typeof candidate.ref === "string" && /^[a-z][a-z0-9]*$/.test(candidate.ref)) return { ref: candidate.ref };
294
- if (
295
- typeof candidate.literal === "string" ||
296
- typeof candidate.literal === "number" ||
297
- typeof candidate.literal === "boolean"
298
- )
299
- return { literal: candidate.literal };
300
- return undefined;
301
- }
302
-
303
- /** Normalizes one reviewed regex rule: the pattern, the flag set and the named capture group list. */
304
- export function regexruleof(value: unknown): regexrule | undefined {
305
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
306
- const candidate = value as Record<string, unknown>;
307
- if (typeof candidate.pattern !== "string" || !candidate.pattern.trim()) return undefined;
308
- if (typeof candidate.flags !== "string" || !/^[dgimsuvy]*$/.test(candidate.flags)) return undefined;
309
- const groups = Array.isArray(candidate.groups)
310
- ? candidate.groups.flatMap((group) => (typeof group === "string" && /^[a-z][a-z0-9]*$/.test(group) ? [group] : []))
311
- : [];
312
- if (candidate.groups !== undefined && groups.length !== (candidate.groups as unknown[]).length) return undefined;
313
- return { pattern: candidate.pattern, flags: candidate.flags, groups };
314
- }
315
-
316
- /** Flattens nested blocks into one executable step list; every flattened step carries the innermost block name so runs highlight the active block, and the nested parameters of an invocation stamp onto the first step of its region so the run binds them into the block scope. Unknown or cyclic block references are refused. */
317
- export function expandblocks(steps: Array<workflowstep | blockinvocation>, blocks: workflowblock[]): workflowstep[] {
318
- const byname = new Map(blocks.map((block) => [block.name, block]));
319
- const expanded: workflowstep[] = [];
320
- const visit = (
321
- entries: Array<workflowstep | blockinvocation>,
322
- path: string[],
323
- inside: string | undefined,
324
- params?: nestedparam[],
325
- ): void => {
326
- let stamped = params === undefined;
327
- for (const entry of entries) {
328
- if ("kind" in entry && "label" in entry && !("block" in entry)) {
329
- const marked = inside === undefined ? entry : { ...entry, block: inside };
330
- if (!stamped && params !== undefined) {
331
- expanded.push({ ...marked, params });
332
- stamped = true;
333
- } else expanded.push(marked);
334
- continue;
335
- }
336
- const invocation = blockinvocationof(entry);
337
- if (!invocation) throw new Error("The step list entry is neither a reviewed step nor a block invocation.");
338
- if (path.includes(invocation.block))
339
- throw new Error(`The block ${invocation.block} recurs inside itself and cannot expand.`);
340
- const block = byname.get(invocation.block);
341
- if (!block) throw new Error(`The block ${invocation.block} is not defined in the workflow.`);
342
- visit(block.steps, [...path, invocation.block], invocation.block, invocation.params ?? params);
343
- }
344
- };
345
- visit(steps, [], undefined);
346
- if (expanded.length === 0) throw new Error("A workflow needs at least one executable step after block expansion.");
347
- return expanded;
348
- }
349
-
350
- /** Composes one workflow record: validates the name, version, origin grants, steps and blocks, expands every block so no step stays hidden, grades the review risk through the injected risk table and freezes the result. */
351
- export function composeworkflow(input: {
352
- id?: string;
353
- name: string;
354
- version: number;
355
- origins: string[];
356
- steps: Array<workflowstep | blockinvocation>;
357
- blocks?: workflowblock[];
358
- now: number;
359
- kindallowed?: (kind: string) => boolean;
360
- riskof?: (kind: string) => actionrisk;
361
- }): workflowrecord {
362
- if (typeof input.name !== "string" || !input.name.trim())
363
- throw new Error("The workflow name must be a non-empty string.");
364
- if (typeof input.version !== "number" || !Number.isInteger(input.version) || input.version < 1)
365
- throw new Error("The workflow version must be a positive integer.");
366
- if (!Array.isArray(input.origins) || input.origins.length === 0)
367
- throw new Error("A workflow needs at least one granted HTTPS origin.");
368
- const origins = input.origins.map((origin) => {
369
- try {
370
- return new URL(origin).origin;
371
- } catch {
372
- throw new Error(`The workflow origin ${origin} is not a valid url.`);
373
- }
374
- });
375
- if (origins.some((origin) => !origin.startsWith("https://"))) throw new Error("Workflow origins must use HTTPS.");
376
- const blocks = input.blocks ?? [];
377
- if (blocks.some((block, index) => blocks.findIndex((other) => other.name === block.name) !== index))
378
- throw new Error("Workflow block names must stay unique.");
379
- for (const entry of input.steps) {
380
- if ("kind" in entry && "label" in entry && !("block" in entry)) {
381
- if (input.kindallowed && !input.kindallowed(entry.kind))
382
- throw new Error(`The workflow step kind ${entry.kind} is not a reviewed action kind.`);
383
- }
384
- }
385
- for (const block of blocks)
386
- for (const entry of block.steps) {
387
- if (
388
- "kind" in entry &&
389
- "label" in entry &&
390
- !("block" in entry) &&
391
- input.kindallowed &&
392
- !input.kindallowed(entry.kind)
393
- )
394
- throw new Error(
395
- `The workflow step kind ${entry.kind} inside block ${block.name} is not a reviewed action kind.`,
396
- );
397
- }
398
- const steps = expandblocks(input.steps, blocks);
399
- for (const step of steps) {
400
- if (input.kindallowed && !input.kindallowed(step.kind))
401
- throw new Error(`The workflow step kind ${step.kind} is not a reviewed action kind.`);
402
- if (iscontrolflowkind(step.kind)) {
403
- validatecontrolpayload(step);
404
- for (const child of controlsteps(step)) {
405
- if (input.kindallowed && !input.kindallowed(child.kind))
406
- throw new Error(
407
- `The workflow step kind ${child.kind} inside the control payload of ${step.id} is not a reviewed action kind.`,
408
- );
409
- }
410
- }
411
- if (step.bindings)
412
- for (const binding of step.bindings) {
413
- if (!steps.some((other) => other.id === binding.stepid))
414
- throw new Error(`The binding of ${binding.variable} references the unknown step ${binding.stepid}.`);
415
- }
416
- }
417
- const riskof = input.riskof ?? ((): actionrisk => "sensitive");
418
- const gradedkinds = steps.flatMap((step) => [step.kind, ...controlsteps(step).map((child) => child.kind)]);
419
- const risk: actionrisk = gradedkinds.some((kind) => riskof(kind) === "sensitive")
420
- ? "sensitive"
421
- : gradedkinds.some((kind) => riskof(kind) === "interaction")
422
- ? "interaction"
423
- : "read";
424
- const record: workflowrecord = {
425
- id: input.id ?? crypto.randomUUID(),
426
- name: input.name,
427
- version: input.version,
428
- origins: [...new Set(origins)],
429
- steps,
430
- blocks,
431
- risk,
432
- createdat: input.now,
433
- };
434
- return deepfreeze(record);
435
- }
436
-
437
- /** Freezes a composed workflow record so later mutations of the shared object graph never rewrite a reviewed workflow. */
438
- function deepfreeze(record: workflowrecord): workflowrecord {
439
- for (const step of record.steps) Object.freeze(step);
440
- for (const block of record.blocks)
441
- for (const entry of block.steps)
442
- if ("kind" in entry && "label" in entry && !("block" in entry)) Object.freeze(entry);
443
- Object.freeze(record.blocks);
444
- Object.freeze(record.steps);
445
- return Object.freeze(record);
446
- }
447
-
448
- /** Validates a composed workflow before any run: the expanded step list, every step kind against the injected allowlist, the bindings against earlier steps and every variable reference against the bindings, the inputs and the root scope. */
449
- export function validateworkflow(
450
- record: workflowrecord,
451
- options?: { kindallowed?: (kind: string) => boolean; inputs?: string[] },
452
- ): { allowed: boolean; reason?: string } {
453
- if (record.steps.length === 0) return { allowed: false, reason: "A workflow needs at least one reviewed step." };
454
- const defined = new Set(options?.inputs ?? []);
455
- const byid = new Map(record.steps.map((step, index) => [step.id, { step, index }]));
456
- for (let index = 0; index < record.steps.length; index += 1) {
457
- const step = record.steps[index] as workflowstep;
458
- if (options?.kindallowed && !options.kindallowed(step.kind))
459
- return { allowed: false, reason: `The workflow step kind ${step.kind} is not a reviewed action kind.` };
460
- if (step.bindings)
461
- for (const binding of step.bindings) {
462
- const source = byid.get(binding.stepid);
463
- if (!source)
464
- return {
465
- allowed: false,
466
- reason: `The binding of ${binding.variable} references the unknown step ${binding.stepid}.`,
467
- };
468
- if (source.index >= index)
469
- return {
470
- allowed: false,
471
- reason: `The binding of ${binding.variable} must link an earlier step than ${step.id}.`,
472
- };
473
- defined.add(binding.variable);
474
- }
475
- if (step.expression) {
476
- for (const operand of [step.expression.left, step.expression.right]) {
477
- if (operand?.ref && !defined.has(operand.ref))
478
- return {
479
- allowed: false,
480
- reason: `The expression of step ${step.id} references the undefined variable ${operand.ref}.`,
481
- };
482
- }
483
- defined.add(step.expression.result);
484
- }
485
- if (step.extract) for (const group of step.extract.groups) defined.add(group);
486
- }
487
- return { allowed: true };
488
- }
489
-
490
- /** Opens one child scope for a block invocation; the parent chain stays intact so resolution walks outward. */
491
- export function pushscope(scopes: variablescope[], name: string, parent?: string): variablescope[] {
492
- return [...scopes, { name, variables: [], ...(parent !== undefined ? { parent } : {}) }];
493
- }
494
-
495
- /** Closes the newest scope and keeps every parent scope intact. */
496
- export function popscope(scopes: variablescope[]): variablescope[] {
497
- if (scopes.length === 0) return scopes;
498
- return scopes.slice(0, -1);
499
- }
500
-
501
- /** Resolves one variable from the nearest scope outward through the parent chain; shadowing follows the newest scope first. */
502
- export function resolvevariable(scopes: variablescope[], name: string): variablevalue | undefined {
503
- for (let index = scopes.length - 1; index >= 0; index -= 1) {
504
- const scope = scopes[index] as variablescope;
505
- const found = scope.variables.find((variable) => variable.name === name);
506
- if (found) return found;
507
- if (scope.parent === undefined) continue;
508
- const parentindex = scopes.findIndex((candidate) => candidate.name === scope.parent);
509
- if (parentindex >= 0 && parentindex < index) {
510
- const inherited = resolvevariable([scopes[parentindex] as variablescope], name);
511
- if (inherited) return inherited;
512
- }
513
- }
514
- return undefined;
515
- }
516
-
517
- /** Writes one variable into the newest scope, replacing a same named value of that scope only. */
518
- export function setvariable(
519
- scopes: variablescope[],
520
- name: string,
521
- kind: variablekind,
522
- value: string | number | boolean | string[],
523
- now: number,
524
- ): variablescope[] {
525
- if (scopes.length === 0) scopes = [{ name: "root", variables: [] }];
526
- const target = scopes[scopes.length - 1] as variablescope;
527
- const variables = [
528
- ...target.variables.filter((variable) => variable.name !== name),
529
- { name, kind, value, setat: now },
530
- ];
531
- return [...scopes.slice(0, -1), { ...target, variables }];
532
- }
533
-
534
- /** Coerces one raw binding value into the reviewed variable kind; mismatched values are refused instead of silently rewritten. */
535
- function coercevariable(value: unknown, kind: variablekind): string | number | boolean | string[] {
536
- if (kind === "number") {
537
- const parsed =
538
- typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
539
- if (!Number.isFinite(parsed)) throw new Error("The bound value is not a finite number.");
540
- return parsed;
541
- }
542
- if (kind === "boolean") {
543
- if (typeof value === "boolean") return value;
544
- if (value === "true") return true;
545
- if (value === "false") return false;
546
- throw new Error("The bound value is not a boolean.");
547
- }
548
- if (kind === "list") {
549
- if (Array.isArray(value)) return value.map((item) => String(item));
550
- if (typeof value === "string") return value.length === 0 ? [] : value.split(",");
551
- throw new Error("The bound value is not a list.");
552
- }
553
- if (kind === "element") {
554
- if (typeof value === "string" && value.trim()) return value;
555
- throw new Error("The bound value is not an element reference.");
556
- }
557
- if (typeof value === "string") return value;
558
- if (typeof value === "number" || typeof value === "boolean") return String(value);
559
- throw new Error("The bound value is not a string.");
560
- }
561
-
562
- /** Reads one dotted path out of a step outcome; an absent path returns the outcome summary. */
563
- function outcomedetail(outcome: stepoutcome, path: string | undefined): unknown {
564
- if (!path) return outcome.summary;
565
- let current: unknown = outcome.details ?? {};
566
- for (const segment of path.split(".")) {
567
- if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
568
- current = (current as Record<string, unknown>)[segment];
569
- }
570
- return current;
571
- }
572
-
573
- /** Resolves every binding whose source step already produced an outcome into the newest scope; the engine runs this before each step so following steps read fresh values. */
574
- export function bindvariables(
575
- scopes: variablescope[],
576
- bindings: variablebinding[],
577
- outputs: Record<string, stepoutcome>,
578
- now: number,
579
- ): { scopes: variablescope[]; produced: string[] } {
580
- let current = scopes;
581
- const produced: string[] = [];
582
- for (const binding of bindings) {
583
- const outcome = outputs[binding.stepid];
584
- if (!outcome) continue;
585
- const raw = outcomedetail(outcome, binding.path);
586
- if (raw === undefined)
587
- throw new Error(
588
- `The binding of ${binding.variable} found no value at ${binding.path ?? "the summary"} of step ${binding.stepid}.`,
589
- );
590
- current = setvariable(current, binding.variable, binding.kind, coercevariable(raw, binding.kind), now);
591
- produced.push(binding.variable);
592
- }
593
- return { scopes: current, produced };
594
- }
595
-
596
- /** Resolves one expression operand: a variable reference resolved from the nearest scope outward or a literal; list values flow through so the list operators handle them while every other operator refuses them at coercion. */
597
- function operandvalue(
598
- operand: { ref?: string; literal?: string | number | boolean },
599
- scopes: variablescope[],
600
- ): string | number | boolean | string[] {
601
- if (operand.ref !== undefined) {
602
- const resolved = resolvevariable(scopes, operand.ref);
603
- if (!resolved) throw new Error(`The expression references the undefined variable ${operand.ref}.`);
604
- return resolved.value;
605
- }
606
- if (operand.literal === undefined) throw new Error("The expression operand needs a variable reference or a literal.");
607
- return operand.literal;
608
- }
609
-
610
- /** Evaluates one reviewed expression between variables: arithmetic, comparison and logic operators with operand coercion and mismatched operator refusals, resolving references from the nearest scope outward; list operands join only the contains and length operators while every other operator refuses them at coercion. */
611
- export function expressioneval(expression: expressiontype, scopes: variablescope[]): string | number | boolean {
612
- const left = operandvalue(expression.left, scopes);
613
- const right = expression.right === undefined ? undefined : operandvalue(expression.right, scopes);
614
- const operand = (value: string | number | boolean | string[] | undefined): string | number | boolean => {
615
- if (Array.isArray(value))
616
- throw new Error("The expression operand is a list and needs the contains or length operator.");
617
- if (value === undefined) throw new Error("The expression operand is missing.");
618
- return value;
619
- };
620
- const numbervalue = (value: string | number | boolean | string[] | undefined): number => {
621
- const primitive = operand(value);
622
- if (typeof primitive === "number") return primitive;
623
- if (typeof primitive === "string" && primitive.trim() !== "") {
624
- const parsed = Number(primitive);
625
- if (Number.isFinite(parsed)) return parsed;
626
- }
627
- throw new Error("The arithmetic operand is not a number.");
628
- };
629
- const booleanvalue = (value: string | number | boolean | string[] | undefined): boolean => {
630
- const primitive = operand(value);
631
- if (typeof primitive === "boolean") return primitive;
632
- throw new Error("The logic operand is not a boolean.");
633
- };
634
- const stringvalue = (value: string | number | boolean | string[] | undefined): string => {
635
- const primitive = operand(value);
636
- if (typeof primitive === "string") return primitive;
637
- if (typeof primitive === "number" || typeof primitive === "boolean") return String(primitive);
638
- throw new Error("The text operand is not a string.");
639
- };
640
- switch (expression.operator) {
641
- case "add":
642
- return numbervalue(left) + numbervalue(right);
643
- case "subtract":
644
- return numbervalue(left) - numbervalue(right);
645
- case "multiply":
646
- return numbervalue(left) * numbervalue(right);
647
- case "divide": {
648
- const divisor = numbervalue(right);
649
- if (divisor === 0) throw new Error("The expression divides by zero.");
650
- return numbervalue(left) / divisor;
651
- }
652
- case "modulo": {
653
- const divisor = numbervalue(right);
654
- if (divisor === 0) throw new Error("The expression divides by zero.");
655
- return numbervalue(left) % divisor;
656
- }
657
- case "equal":
658
- return left === right;
659
- case "notequal":
660
- return left !== right;
661
- case "less":
662
- return numbervalue(left) < numbervalue(right);
663
- case "greater":
664
- return numbervalue(left) > numbervalue(right);
665
- case "lessequal":
666
- return numbervalue(left) <= numbervalue(right);
667
- case "greaterequal":
668
- return numbervalue(left) >= numbervalue(right);
669
- case "and":
670
- return booleanvalue(left) && booleanvalue(right);
671
- case "or":
672
- return booleanvalue(left) || booleanvalue(right);
673
- case "not":
674
- return !booleanvalue(left);
675
- case "concat":
676
- return `${stringvalue(left)}${stringvalue(right)}`;
677
- case "contains": {
678
- if (Array.isArray(left)) return left.includes(stringvalue(right));
679
- return stringvalue(left).includes(stringvalue(right));
680
- }
681
- case "length": {
682
- if (Array.isArray(left)) return left.length;
683
- return stringvalue(left).length;
684
- }
685
- default:
686
- throw new Error("The reviewed expression operator is unknown.");
687
- }
688
- }
689
-
690
- /** Applies one reviewed regex rule to text and stores the named capture groups as string variables; the no match case is an honest outcome instead of a crash. */
691
- export function regexextract(
692
- rule: regexrule,
693
- text: string,
694
- now: number,
695
- ): { matched: boolean; variables: variablevalue[] } {
696
- const pattern = new RegExp(rule.pattern, rule.flags);
697
- const match = pattern.exec(text);
698
- if (!match) return { matched: false, variables: [] };
699
- const variables: variablevalue[] = [];
700
- for (const group of rule.groups) {
701
- const value = match.groups?.[group];
702
- variables.push({ name: group, kind: "string", value: typeof value === "string" ? value : "", setat: now });
703
- }
704
- return { matched: true, variables };
705
- }
706
-
707
- /** Plans the element wait polling: how many probe passes fit inside the reviewed timeout window at the reviewed poll interval; a zero timeout or a zero poll interval runs a single immediate probe. */
708
- export function waitelementplan(wait: { timeout: number; poll: number }): { probes: number; lastwait: number } {
709
- if (wait.timeout <= 0 || wait.poll <= 0) return { probes: 1, lastwait: 0 };
710
- const probes = Math.floor(wait.timeout / wait.poll) + 1;
711
- return { probes, lastwait: wait.timeout % wait.poll };
712
- }
713
-
714
- /** Samples one delay inside the reviewed jitter window from a seeded random source: the window spans base minus half the jitter to base plus half the jitter and never dips below zero. */
715
- export function delayjitter(delay: delaystep, seed: number): number {
716
- if (delay.jitter <= 0) return Math.max(0, delay.base);
717
- const sample = seededrandom(seed);
718
- return Math.max(0, delay.base - delay.jitter / 2 + sample * delay.jitter);
719
- }
720
-
721
- /** Deterministic random source of the delay jitter so reviewed windows replay exactly during tests and audits; the seed passes an avalanche mix before the xorshift steps so nearby seeds spread across the whole window. */
722
- export function seededrandom(seed: number): number {
723
- let state = seed >>> 0;
724
- state ^= state >>> 16;
725
- state = Math.imul(state, 0x85ebca6b);
726
- state ^= state >>> 13;
727
- state = Math.imul(state, 0xc2b2ae35);
728
- state ^= state >>> 16;
729
- state = state >>> 0 || 1;
730
- state ^= state << 13;
731
- state >>>= 0;
732
- state ^= state >> 17;
733
- state ^= state << 5;
734
- state >>>= 0;
735
- return state / 0x100000000;
736
- }
737
-
738
- /** Builds one new workflow run: pending state, a zero step cursor and the optional dry run flag. */
739
- export function newworkflowrun(input: { id?: string; workflowid: string; dryrun?: boolean; now: number }): workflowrun {
740
- return {
741
- id: input.id ?? crypto.randomUUID(),
742
- workflowid: input.workflowid,
743
- state: "pending",
744
- cursor: 0,
745
- startedat: input.now,
746
- ...(input.dryrun === true ? { dryrun: true } : {}),
747
- };
748
- }
749
-
750
- /** Pauses one running workflow run at its last checkpoint; the cursor keeps the completed steps so a resume continues exactly there. */
751
- export function pauserun(run: workflowrun, now: number): workflowrun {
752
- if (run.state !== "running") throw new Error("Only a running workflow can pause.");
753
- return { ...run, state: "paused", pausedat: now };
754
- }
755
-
756
- /** Cancels one workflow run and records the reviewed reason; done and already cancelled runs stay untouched. */
757
- export function cancelrun(run: workflowrun, reason: string, now: number): workflowrun {
758
- if (run.state === "done" || run.state === "cancelled") return run;
759
- return { ...run, state: "cancelled", cancelreason: reason, endedat: now };
760
- }
761
-
762
- /** Substitutes ${name} variable references of one step field from the scopes; undefined references are refused with the variable name. */
763
- function interpolate(text: string, scopes: variablescope[]): { text: string; consumed: string[] } {
764
- const consumed: string[] = [];
765
- const resolved = text.replace(/\$\{([a-z][a-z0-9]*)\}/g, (_whole, name: string) => {
766
- const variable = resolvevariable(scopes, name);
767
- if (!variable) throw new Error(`The step references the undefined variable ${name}.`);
768
- consumed.push(name);
769
- return Array.isArray(variable.value) ? variable.value.join(",") : String(variable.value);
770
- });
771
- return { text: resolved, consumed };
772
- }
773
-
774
- /** Builds the runlog entry of one finished workflow step. */
775
- function runlogof(
776
- step: workflowstep,
777
- state: runlogentry["state"],
778
- startedat: number,
779
- duration: number,
780
- summary: string,
781
- extra: {
782
- block?: string;
783
- consumed?: string[];
784
- produced?: string[];
785
- details?: Record<string, unknown>;
786
- checkpoint?: boolean;
787
- },
788
- ): runlogentry {
789
- return {
790
- stepid: step.id,
791
- label: step.label,
792
- state,
793
- startedat,
794
- duration,
795
- summary,
796
- ...(extra.block !== undefined ? { block: extra.block } : {}),
797
- ...(extra.consumed !== undefined && extra.consumed.length > 0 ? { consumed: extra.consumed } : {}),
798
- ...(extra.produced !== undefined && extra.produced.length > 0 ? { produced: extra.produced } : {}),
799
- ...(extra.checkpoint === true ? { checkpoint: true } : {}),
800
- ...(extra.details !== undefined ? { details: extra.details } : {}),
801
- };
802
- }
803
-
804
- /** Executes exactly one workflow step outside the run loop: resolves the bindings of earlier steps, evaluates the inline expression and regex rule, interpolates the variable references, dispatches through the injected executor and binds the outcome into the newest scope; a control flow executor returns the merged scopes and its iteration runlog so the step adopts them before its own entry. */
805
- export async function runstep(input: {
806
- step: workflowstep;
807
- scopes: variablescope[];
808
- outputs: Record<string, stepoutcome>;
809
- execute: (
810
- step: workflowstep,
811
- context: { scopes: variablescope[]; block?: string; outputs?: Record<string, stepoutcome> },
812
- ) => Promise<stepexecution>;
813
- now: number;
814
- block?: string;
815
- }): Promise<{ scopes: variablescope[]; log: runlogentry; childlog?: runlogentry[]; output: stepexecution }> {
816
- const startedat = input.now;
817
- let scopes = input.scopes;
818
- const consumed: string[] = [];
819
- if (input.step.bindings) {
820
- const bound = bindvariables(
821
- scopes,
822
- input.step.bindings.filter((binding) => input.outputs[binding.stepid] !== undefined),
823
- input.outputs,
824
- input.now,
825
- );
826
- scopes = bound.scopes;
827
- }
828
- let produced: string[] = [];
829
- try {
830
- if (input.step.expression) {
831
- const value = expressioneval(input.step.expression, scopes);
832
- scopes = setvariable(
833
- scopes,
834
- input.step.expression.result,
835
- input.step.expression.resultkind,
836
- coercevariable(value, input.step.expression.resultkind),
837
- input.now,
838
- );
839
- produced = [...produced, input.step.expression.result];
840
- }
841
- let stepvalue = input.step.value;
842
- if (input.step.extract) {
843
- const text = stepvalue ?? "";
844
- const interpolated = interpolate(text, scopes);
845
- consumed.push(...interpolated.consumed);
846
- const extraction = regexextract(input.step.extract, interpolated.text, input.now);
847
- if (extraction.matched) {
848
- for (const variable of extraction.variables)
849
- scopes = setvariable(scopes, variable.name, "string", variable.value, input.now);
850
- produced = [...produced, ...extraction.variables.map((variable) => variable.name)];
851
- }
852
- stepvalue = interpolated.text;
853
- }
854
- // Control flow steps skip interpolation: the control engine owns variable rebinding inside its payload (the item and index variables of every iteration), so it dispatches the reviewed payload unchanged and interpolates the child steps itself once the iteration scopes are bound.
855
- const controlled = iscontrolflowkind(input.step.kind);
856
- const target = !controlled && input.step.target !== undefined ? interpolate(input.step.target, scopes) : undefined;
857
- if (target) consumed.push(...target.consumed);
858
- const value = !controlled && stepvalue !== undefined ? interpolate(stepvalue, scopes) : undefined;
859
- if (value) consumed.push(...value.consumed);
860
- const options =
861
- !controlled && input.step.options !== undefined ? interpolate(input.step.options, scopes) : undefined;
862
- if (options) consumed.push(...options.consumed);
863
- const dispatchable: workflowstep = {
864
- ...input.step,
865
- ...(target !== undefined ? { target: target.text } : {}),
866
- ...(value !== undefined ? { value: value.text } : {}),
867
- ...(options !== undefined ? { options: options.text } : {}),
868
- };
869
- const output = await input.execute(dispatchable, {
870
- scopes,
871
- outputs: input.outputs,
872
- ...(input.block !== undefined ? { block: input.block } : {}),
873
- });
874
- if (output.scopes !== undefined) scopes = output.scopes;
875
- const childlog = output.log;
876
- if (input.step.bindings) {
877
- const bound = bindvariables(
878
- scopes,
879
- input.step.bindings,
880
- {
881
- ...input.outputs,
882
- [input.step.id]: {
883
- stepid: input.step.id,
884
- ok: output.ok,
885
- summary: output.summary,
886
- ...(output.details !== undefined ? { details: output.details } : {}),
887
- at: input.now,
888
- },
889
- },
890
- input.now,
891
- );
892
- scopes = bound.scopes;
893
- produced = [...new Set([...produced, ...bound.produced])];
894
- }
895
- const duration = Date.now() - startedat;
896
- return {
897
- scopes,
898
- log: runlogof(input.step, output.ok ? "done" : "failed", startedat, duration, output.summary, {
899
- ...(input.block !== undefined ? { block: input.block } : {}),
900
- ...(consumed.length > 0 ? { consumed } : {}),
901
- ...(produced.length > 0 ? { produced } : {}),
902
- ...(output.details !== undefined ? { details: output.details } : {}),
903
- ...(output.ok ? { checkpoint: true } : {}),
904
- }),
905
- ...(childlog !== undefined ? { childlog } : {}),
906
- output,
907
- };
908
- } catch (error) {
909
- const duration = Date.now() - startedat;
910
- const summary = error instanceof Error ? error.message : String(error);
911
- return {
912
- scopes,
913
- log: runlogof(input.step, "failed", startedat, duration, summary, {
914
- ...(input.block !== undefined ? { block: input.block } : {}),
915
- ...(consumed.length > 0 ? { consumed } : {}),
916
- }),
917
- output: { ok: false, summary },
918
- };
919
- }
920
- }
921
-
922
- /** Advances one workflow run one step at a time: gates the run behind the active session, the approved plan and the origin grants, opens a child scope per block region, checkpoints after every completed step and resumes a paused run from its last checkpoint. */
923
- export async function runworkflow(input: {
924
- record: workflowrecord;
925
- run: workflowrun;
926
- scopes?: variablescope[];
927
- log?: runlogentry[];
928
- outputs?: Record<string, stepoutcome>;
929
- execute: (
930
- step: workflowstep,
931
- context: { scopes: variablescope[]; block?: string; outputs?: Record<string, stepoutcome> },
932
- ) => Promise<stepexecution>;
933
- now: number;
934
- gates?: { sessionactive: boolean; planapproved: boolean; origingranted: (origin: string) => boolean };
935
- oncheckpoint?: (state: { run: workflowrun; scopes: variablescope[]; log: runlogentry[] }) => Promise<void> | void;
936
- }): Promise<{ run: workflowrun; scopes: variablescope[]; log: runlogentry[]; outputs: Record<string, stepoutcome> }> {
937
- if (input.gates && !input.gates.sessionactive)
938
- throw new Error("The workflow refuses to run outside an approved session.");
939
- if (input.gates && !input.gates.planapproved)
940
- throw new Error("The workflow refuses to run without the approved plan review.");
941
- if (input.gates)
942
- for (const origin of input.record.origins) {
943
- if (!input.gates.origingranted(origin))
944
- throw new Error(`The workflow origin ${origin} falls outside the session grants.`);
945
- }
946
- if (input.run.state === "done" || input.run.state === "failed" || input.run.state === "cancelled")
947
- throw new Error(`The workflow run is already ${input.run.state}.`);
948
- const { pausedat, ...resumed } = input.run;
949
- void pausedat;
950
- let run: workflowrun =
951
- input.run.state === "paused" ? { ...resumed, state: "running" } : { ...input.run, state: "running" };
952
- let scopes = input.scopes ?? [{ name: "root", variables: [] }];
953
- const log = [...(input.log ?? [])];
954
- const outputs: Record<string, stepoutcome> = { ...(input.outputs ?? {}) };
955
- let activeblock: string | undefined;
956
- for (let index = run.cursor; index < input.record.steps.length; index += 1) {
957
- const step = input.record.steps[index] as workflowstep;
958
- if (step.block !== undefined && step.block !== activeblock) {
959
- scopes = pushscope(scopes, step.block, (scopes[scopes.length - 1] as variablescope).name);
960
- activeblock = step.block;
961
- // The nested parameters of the block invocation bind into the fresh child scope before its first step runs; a default of the wrong kind fails the run honestly with the parameter name.
962
- if (step.params) {
963
- try {
964
- for (const param of step.params) {
965
- if (param.default === undefined) continue;
966
- scopes = setvariable(scopes, param.name, param.kind, coercevariable(param.default, param.kind), input.now);
967
- }
968
- } catch (error) {
969
- const reason = error instanceof Error ? error.message : String(error);
970
- return {
971
- run: {
972
- ...run,
973
- state: "failed",
974
- endedat: Date.now(),
975
- failreason: `The nested parameter of block ${step.block} failed: ${reason}`,
976
- },
977
- scopes,
978
- log,
979
- outputs,
980
- };
981
- }
982
- }
983
- } else if (step.block === undefined && activeblock !== undefined) {
984
- while (scopes.length > 1) scopes = popscope(scopes);
985
- activeblock = undefined;
986
- }
987
- const executed = await runstep({
988
- step,
989
- scopes,
990
- outputs,
991
- execute: input.execute,
992
- now: Date.now(),
993
- ...(step.block !== undefined ? { block: step.block } : {}),
994
- });
995
- scopes = executed.scopes;
996
- if (executed.childlog !== undefined) log.push(...executed.childlog);
997
- log.push(executed.log);
998
- outputs[step.id] = {
999
- stepid: step.id,
1000
- ok: executed.output.ok,
1001
- summary: executed.output.summary,
1002
- ...(executed.output.details !== undefined ? { details: executed.output.details } : {}),
1003
- at: Date.now(),
1004
- };
1005
- if (!executed.output.ok) {
1006
- run = { ...run, state: "failed", endedat: Date.now(), failreason: executed.output.summary };
1007
- return { run, scopes, log, outputs };
1008
- }
1009
- run = { ...run, cursor: index + 1 };
1010
- if (input.oncheckpoint) await input.oncheckpoint({ run, scopes, log });
1011
- }
1012
- run = { ...run, state: "done", endedat: Date.now() };
1013
- return { run, scopes, log, outputs };
1014
- }
1015
-
1016
- /** Evaluates every step of a workflow with no page mutation and no storage write: steps with a read only projection record their would be outcome and every other step is refused in the runlog. */
1017
- export function dryrunworkflow(input: {
1018
- record: workflowrecord;
1019
- run: workflowrun;
1020
- scopes?: variablescope[];
1021
- log?: runlogentry[];
1022
- now: number;
1023
- projection: (step: workflowstep) => string | undefined;
1024
- }): { run: workflowrun; scopes: variablescope[]; log: runlogentry[] } {
1025
- const run: workflowrun = {
1026
- ...input.run,
1027
- state: "running",
1028
- ...(input.run.dryrun === true ? { dryrun: true } : { dryrun: true }),
1029
- };
1030
- let scopes = input.scopes ?? [{ name: "root", variables: [] }];
1031
- const log = [...(input.log ?? [])];
1032
- for (let index = run.cursor; index < input.record.steps.length; index += 1) {
1033
- const step = input.record.steps[index] as workflowstep;
1034
- const summary = input.projection(step);
1035
- const entry =
1036
- summary === undefined
1037
- ? runlogof(
1038
- step,
1039
- "refused",
1040
- input.now,
1041
- 0,
1042
- `The ${step.kind} step has no read only projection and the dry run refuses it.`,
1043
- { ...(step.block !== undefined ? { block: step.block } : {}) },
1044
- )
1045
- : runlogof(step, "done", input.now, 0, summary, { ...(step.block !== undefined ? { block: step.block } : {}) });
1046
- log.push(entry);
1047
- scopes = setvariable(scopes, `${step.id}outcome`, "boolean", entry.state === "done", input.now);
1048
- }
1049
- return { run: { ...run, state: "done", cursor: input.record.steps.length, endedat: input.now }, scopes, log };
1050
- }
1051
-
1052
- /** One watchdog verdict of a running workflow run: the verdict, the recovery action the configuration picks and the honest reason. */
1053
- export type watchdogverdict = {
1054
- runid: string;
1055
- verdict: "stalled" | "zombie" | "healthy";
1056
- action: "retry" | "pause" | "cancel" | "reap" | "none";
1057
- reason: string;
1058
- lastcompletedat?: number;
1059
- };
1060
-
1061
- /** Scans the running workflow runs for stalled steps and zombie runs: a run grades stalled when no step completed inside the configured threshold and it grades zombie when its executor is gone — a browser shutdown left it running — and the window elapsed; the recovery action stays the reviewed user configuration of retry, pause or cancel while a zombie always reaps. */
1062
- export function watchdogpass(input: {
1063
- runs: workflowrun[];
1064
- lastcompletedat: Record<string, number>;
1065
- liveexecutors: string[];
1066
- config: watchdogconfig;
1067
- now: number;
1068
- }): watchdogverdict[] {
1069
- const verdicts: watchdogverdict[] = [];
1070
- for (const run of input.runs) {
1071
- if (run.state !== "running") continue;
1072
- const lastcompletedat = input.lastcompletedat[run.id] ?? run.startedat;
1073
- const live = input.liveexecutors.includes(run.id);
1074
- const silence = input.now - lastcompletedat;
1075
- if (!live && input.config.zombiewindow !== undefined && silence >= input.config.zombiewindow) {
1076
- verdicts.push({
1077
- runid: run.id,
1078
- verdict: "zombie",
1079
- action: "reap",
1080
- reason: `The run ${run.id} lost its executor ${silence} ms ago and reaps as a zombie of a browser shutdown at its last checkpoint ${run.cursor}.`,
1081
- ...(lastcompletedat !== run.startedat ? { lastcompletedat } : {}),
1082
- });
1083
- continue;
1084
- }
1085
- if (!live) continue;
1086
- if (silence >= input.config.stallthreshold) {
1087
- const action = input.config.action;
1088
- verdicts.push({
1089
- runid: run.id,
1090
- verdict: "stalled",
1091
- action,
1092
- reason: `The run ${run.id} completed no step for ${silence} ms past the reviewed threshold and the watchdog recovers it with ${action} at cursor ${run.cursor}.`,
1093
- ...(lastcompletedat !== run.startedat ? { lastcompletedat } : {}),
1094
- });
1095
- continue;
1096
- }
1097
- verdicts.push({
1098
- runid: run.id,
1099
- verdict: "healthy",
1100
- action: "none",
1101
- reason: `The run ${run.id} completed its last step ${silence} ms ago and stays healthy.`,
1102
- ...(lastcompletedat !== run.startedat ? { lastcompletedat } : {}),
1103
- });
1104
- }
1105
- return verdicts;
1106
- }
1107
-
1108
- /* ── Merged from controlflow.ts ── */
1109
-
1110
- /**
1111
- * Control flow engine for the 1.1.51 workflow family.
1112
- * Every correlated rule of the second workflow phase lives in this file: the control flow kind list, the condition, branch, loop, repeat until, while, foreach, parallel, join, try and retry payload normalizers, the condition evaluation over extracted values, the branch selection by page state, the loop runners that rebind the item and index per iteration inside the reviewed safety bounds, the parallel runner with isolated branch scopes and the join that merges branch outcomes under the reviewed strategy, the try runner with its catch handler and rerun option, the retry runner with fixed and exponential backoff shapes, the step and run timeout aborts with the cancelled error class, and the control summaries the review panel renders.
1113
- * The engine stays pure: child steps dispatch through the injected executor seam, element resolution flows through the injected resolver and page state flows through the injected snapshot accessor, so tests run on plain fixtures and no control construct bypasses the consent gates.
1114
- */
1115
-
1116
- /** The control flow kinds of the 1.1.51 family: conditionals, branching, loops, parallel branches with joins and try catch with retries and timeouts. */
1117
- export const controlflowkinds: string[] = [
1118
- "condition",
1119
- "branch",
1120
- "loop",
1121
- "repeatuntil",
1122
- "whileloop",
1123
- "foreach",
1124
- "parallel",
1125
- "trycatch",
1126
- ];
1127
-
1128
- /** The documented default safety bound of the loop family when the review configures none; it is a documented default, never a cap, and any user configured bound wins. */
1129
- export const defaultloopbound = 1000;
1130
-
1131
- /** The executor seam of the control flow engine: the same seam the run loop uses, extended with the outputs of the earlier steps so child bindings resolve. */
1132
- export type controlexecute = (
1133
- step: workflowstep,
1134
- context: { scopes: variablescope[]; block?: string; outputs?: Record<string, stepoutcome> },
1135
- ) => Promise<stepexecution>;
1136
-
1137
- /** True when the kind belongs to the control flow family of the 1.1.51 release. */
1138
- export function iscontrolflowkind(kind: string): boolean {
1139
- return controlflowkinds.includes(kind);
1140
- }
1141
-
1142
- /** The cancelled error class of timeout aborts: a step or a whole run cancelled because it exceeded its reviewed budget. */
1143
- export class cancellederror extends Error {
1144
- constructor(message: string) {
1145
- super(message);
1146
- this.name = "cancellederror";
1147
- }
1148
- }
1149
-
1150
- /** Normalizes one lowercase control identifier of the grammar: path names, branch ids and variable names. */
1151
- function controlname(value: unknown): string | undefined {
1152
- return typeof value === "string" && /^[a-z][a-z0-9]*$/.test(value) ? value : undefined;
1153
- }
1154
-
1155
- /** Normalizes one child step list of a control payload; an empty list is refused for bodies while the branch else path may skip explicitly. */
1156
- function controlstepslist(value: unknown): workflowstep[] | undefined {
1157
- if (!Array.isArray(value) || value.length === 0) return undefined;
1158
- const steps: workflowstep[] = [];
1159
- for (const entry of value) {
1160
- const parsed = workflowstepof(entry);
1161
- if (!parsed) return undefined;
1162
- steps.push(parsed);
1163
- }
1164
- return steps;
1165
- }
1166
-
1167
- /** Normalizes one optional positive integer bound of the loop family. */
1168
- function controlbound(value: unknown): number | undefined {
1169
- if (value === undefined) return undefined;
1170
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
1171
- }
1172
-
1173
- /** Normalizes one condition payload: the reviewed boolean expression tested over the extracted values. */
1174
- export function conditionof(value: unknown): conditionstep | undefined {
1175
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1176
- const candidate = value as Record<string, unknown>;
1177
- const expression = expressionof(candidate.expression);
1178
- if (!expression) return undefined;
1179
- if (expression.resultkind !== "boolean") return undefined;
1180
- return { expression };
1181
- }
1182
-
1183
- /** Normalizes one else path of a branch payload: the path name, no match condition and the child steps; an empty step list is the explicit skip. */
1184
- function elseof(value: unknown): branchpath | undefined {
1185
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1186
- const candidate = value as Record<string, unknown>;
1187
- const name = controlname(candidate.name);
1188
- if (!name) return undefined;
1189
- if (candidate.when !== undefined) return undefined;
1190
- if (!Array.isArray(candidate.steps)) return undefined;
1191
- const steps: workflowstep[] = [];
1192
- for (const entry of candidate.steps) {
1193
- const parsed = workflowstepof(entry);
1194
- if (!parsed) return undefined;
1195
- steps.push(parsed);
1196
- }
1197
- return { name, steps };
1198
- }
1199
-
1200
- /** Normalizes one branch payload: the unique named paths with their boolean match expressions and the mandatory else path that terminates every branch. */
1201
- export function branchof(value: unknown): branchstep | undefined {
1202
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1203
- const candidate = value as Record<string, unknown>;
1204
- if (!Array.isArray(candidate.paths) || candidate.paths.length === 0) return undefined;
1205
- const paths: branchpath[] = [];
1206
- for (const entry of candidate.paths) {
1207
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
1208
- const path = entry as Record<string, unknown>;
1209
- const name = controlname(path.name);
1210
- if (!name) return undefined;
1211
- const when = path.when === undefined ? undefined : expressionof(path.when);
1212
- if (path.when !== undefined && when === undefined) return undefined;
1213
- if (when !== undefined && when.resultkind !== "boolean") return undefined;
1214
- const steps = controlstepslist(path.steps);
1215
- if (!steps) return undefined;
1216
- paths.push({ name, ...(when !== undefined ? { when } : {}), steps });
1217
- }
1218
- const names = paths.map((path) => path.name);
1219
- if (new Set(names).size !== names.length) return undefined;
1220
- const elsepath = elseof(candidate.else);
1221
- if (!elsepath) return undefined;
1222
- if (names.includes(elsepath.name)) return undefined;
1223
- return { paths, else: elsepath };
1224
- }
1225
-
1226
- /** Normalizes one loop payload: the list variable, the item and index variables, the optional safety bound and the non-empty body. */
1227
- export function loopof(value: unknown): loopstep | undefined {
1228
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1229
- const candidate = value as Record<string, unknown>;
1230
- const list = controlname(candidate.list);
1231
- const item = controlname(candidate.item);
1232
- const index = controlname(candidate.index);
1233
- if (!list || !item || !index) return undefined;
1234
- if (item === list || index === list || item === index) return undefined;
1235
- const bound = controlbound(candidate.bound);
1236
- if (candidate.bound !== undefined && bound === undefined) return undefined;
1237
- const steps = controlstepslist(candidate.steps);
1238
- if (!steps) return undefined;
1239
- return { list, item, index, ...(bound !== undefined ? { bound } : {}), steps };
1240
- }
1241
-
1242
- /** Normalizes one repeat until payload: the convergence expression, the optional safety bound and the non-empty body. */
1243
- export function repeatuntilof(value: unknown): repeatuntilstep | undefined {
1244
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1245
- const candidate = value as Record<string, unknown>;
1246
- const until = expressionof(candidate.until);
1247
- if (!until || until.resultkind !== "boolean") return undefined;
1248
- const bound = controlbound(candidate.bound);
1249
- if (candidate.bound !== undefined && bound === undefined) return undefined;
1250
- const steps = controlstepslist(candidate.steps);
1251
- if (!steps) return undefined;
1252
- return { until, ...(bound !== undefined ? { bound } : {}), steps };
1253
- }
1254
-
1255
- /** Normalizes one while payload: the loop condition, the mandatory safety bound and the non-empty body; a while loop without a bound is refused. */
1256
- export function whileof(value: unknown): whilestep | undefined {
1257
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1258
- const candidate = value as Record<string, unknown>;
1259
- const condition = expressionof(candidate.while);
1260
- if (!condition || condition.resultkind !== "boolean") return undefined;
1261
- const bound = controlbound(candidate.bound);
1262
- if (bound === undefined) return undefined;
1263
- const steps = controlstepslist(candidate.steps);
1264
- if (!steps) return undefined;
1265
- return { while: condition, bound, steps };
1266
- }
1267
-
1268
- /** Normalizes one foreach payload: the non-empty selector, the item and index variables and the non-empty body. */
1269
- export function foreachof(value: unknown): foreachstep | undefined {
1270
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1271
- const candidate = value as Record<string, unknown>;
1272
- if (typeof candidate.selector !== "string" || !candidate.selector.trim()) return undefined;
1273
- const item = controlname(candidate.item);
1274
- const index = controlname(candidate.index);
1275
- if (!item || !index || item === index) return undefined;
1276
- const steps = controlstepslist(candidate.steps);
1277
- if (!steps) return undefined;
1278
- return { selector: candidate.selector, item, index, steps };
1279
- }
1280
-
1281
- /** Normalizes one parallel payload: the uniquely identified branches with non-empty bodies and the join policy. */
1282
- export function parallelof(value: unknown): parallelstep | undefined {
1283
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1284
- const candidate = value as Record<string, unknown>;
1285
- if (!Array.isArray(candidate.branches) || candidate.branches.length === 0) return undefined;
1286
- const branches: parallelstep["branches"] = [];
1287
- for (const entry of candidate.branches) {
1288
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
1289
- const branch = entry as Record<string, unknown>;
1290
- const id = controlname(branch.id);
1291
- if (!id) return undefined;
1292
- const steps = controlstepslist(branch.steps);
1293
- if (!steps) return undefined;
1294
- branches.push({ id, steps });
1295
- }
1296
- if (new Set(branches.map((branch) => branch.id)).size !== branches.length) return undefined;
1297
- const join =
1298
- candidate.join && typeof candidate.join === "object" && !Array.isArray(candidate.join)
1299
- ? (candidate.join as Record<string, unknown>)
1300
- : undefined;
1301
- if (!join) return undefined;
1302
- if (join.strategy !== "first" && join.strategy !== "last" && join.strategy !== "fail") return undefined;
1303
- if (join.onfail !== "cancel" && join.onfail !== "continue") return undefined;
1304
- return { branches, join: { strategy: join.strategy, onfail: join.onfail } };
1305
- }
1306
-
1307
- /** Normalizes one try payload: the fragile body, the error handler with its rerun option, the optional retry policy and the optional timeout policy. */
1308
- export function tryof(value: unknown): trystep | undefined {
1309
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1310
- const candidate = value as Record<string, unknown>;
1311
- const steps = controlstepslist(candidate.steps);
1312
- if (!steps) return undefined;
1313
- const catchcandidate =
1314
- candidate.catch && typeof candidate.catch === "object" && !Array.isArray(candidate.catch)
1315
- ? (candidate.catch as Record<string, unknown>)
1316
- : undefined;
1317
- if (!catchcandidate) return undefined;
1318
- const catchsteps = controlstepslist(catchcandidate.steps);
1319
- if (!catchsteps) return undefined;
1320
- if (catchcandidate.rerun !== undefined && typeof catchcandidate.rerun !== "boolean") return undefined;
1321
- const catchvalue: errorhandler = { steps: catchsteps, ...(catchcandidate.rerun === true ? { rerun: true } : {}) };
1322
- let retry: trystep["retry"];
1323
- if (candidate.retry !== undefined) {
1324
- const retrycandidate =
1325
- candidate.retry && typeof candidate.retry === "object" && !Array.isArray(candidate.retry)
1326
- ? (candidate.retry as Record<string, unknown>)
1327
- : undefined;
1328
- if (!retrycandidate) return undefined;
1329
- if (
1330
- typeof retrycandidate.attempts !== "number" ||
1331
- !Number.isInteger(retrycandidate.attempts) ||
1332
- retrycandidate.attempts < 1
1333
- )
1334
- return undefined;
1335
- const backoff =
1336
- retrycandidate.backoff && typeof retrycandidate.backoff === "object" && !Array.isArray(retrycandidate.backoff)
1337
- ? (retrycandidate.backoff as Record<string, unknown>)
1338
- : undefined;
1339
- if (!backoff) return undefined;
1340
- if (backoff.shape !== "fixed" && backoff.shape !== "exponential") return undefined;
1341
- if (typeof backoff.base !== "number" || !Number.isFinite(backoff.base) || backoff.base < 0) return undefined;
1342
- if (typeof backoff.jitter !== "number" || !Number.isFinite(backoff.jitter) || backoff.jitter < 0) return undefined;
1343
- if (
1344
- !Array.isArray(retrycandidate.retryable) ||
1345
- !retrycandidate.retryable.every((entry) => typeof entry === "string" && entry.trim())
1346
- )
1347
- return undefined;
1348
- retry = {
1349
- attempts: retrycandidate.attempts,
1350
- backoff: { shape: backoff.shape, base: backoff.base, jitter: backoff.jitter },
1351
- retryable: retrycandidate.retryable as string[],
1352
- };
1353
- }
1354
- let timeout: trystep["timeout"];
1355
- if (candidate.timeout !== undefined) {
1356
- const timeoutcandidate =
1357
- candidate.timeout && typeof candidate.timeout === "object" && !Array.isArray(candidate.timeout)
1358
- ? (candidate.timeout as Record<string, unknown>)
1359
- : undefined;
1360
- if (!timeoutcandidate) return undefined;
1361
- const stepms =
1362
- timeoutcandidate.stepms === undefined
1363
- ? undefined
1364
- : typeof timeoutcandidate.stepms === "number" &&
1365
- Number.isFinite(timeoutcandidate.stepms) &&
1366
- timeoutcandidate.stepms > 0
1367
- ? timeoutcandidate.stepms
1368
- : undefined;
1369
- const runms =
1370
- timeoutcandidate.runms === undefined
1371
- ? undefined
1372
- : typeof timeoutcandidate.runms === "number" &&
1373
- Number.isFinite(timeoutcandidate.runms) &&
1374
- timeoutcandidate.runms > 0
1375
- ? timeoutcandidate.runms
1376
- : undefined;
1377
- if (stepms === undefined && runms === undefined) return undefined;
1378
- if (timeoutcandidate.stepms !== undefined && stepms === undefined) return undefined;
1379
- if (timeoutcandidate.runms !== undefined && runms === undefined) return undefined;
1380
- timeout = { ...(stepms !== undefined ? { stepms } : {}), ...(runms !== undefined ? { runms } : {}) };
1381
- }
1382
- return {
1383
- steps,
1384
- catch: catchvalue,
1385
- ...(retry !== undefined ? { retry } : {}),
1386
- ...(timeout !== undefined ? { timeout } : {}),
1387
- };
1388
- }
1389
-
1390
- /** Parses the reviewed control payload of one step; malformed payloads are refused with the kind name. */
1391
- function controloptions(step: workflowstep): Record<string, unknown> {
1392
- if (step.options === undefined)
1393
- throw new Error(`The ${step.kind} step needs its reviewed control payload in options.`);
1394
- let parsed: unknown;
1395
- try {
1396
- parsed = JSON.parse(step.options);
1397
- } catch {
1398
- throw new Error(`The ${step.kind} control payload must be a JSON object.`);
1399
- }
1400
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1401
- throw new Error(`The ${step.kind} control payload must be a JSON object.`);
1402
- return parsed as Record<string, unknown>;
1403
- }
1404
-
1405
- /** Validates the control payload of one composed step; throws with the kind specific rule when the payload is malformed so composition refuses it before any review. */
1406
- export function validatecontrolpayload(step: workflowstep): void {
1407
- if (!iscontrolflowkind(step.kind)) return;
1408
- const payload = controloptions(step);
1409
- if (step.kind === "condition" && conditionof(payload.condition) === undefined)
1410
- throw new Error("The condition step needs a reviewed boolean expression in its options.");
1411
- if (step.kind === "branch" && branchof(payload.branch) === undefined)
1412
- throw new Error(
1413
- "The branch step needs reviewed unique paths with boolean match expressions and an else path in its options.",
1414
- );
1415
- if (step.kind === "loop" && loopof(payload.loop) === undefined)
1416
- throw new Error(
1417
- "The loop step needs a reviewed list variable, distinct item and index variables, an optional positive safety bound and a non-empty body in its options.",
1418
- );
1419
- if (step.kind === "repeatuntil" && repeatuntilof(payload.repeatuntil) === undefined)
1420
- throw new Error(
1421
- "The repeat until step needs a reviewed convergence expression, an optional positive safety bound and a non-empty body in its options.",
1422
- );
1423
- if (step.kind === "whileloop" && whileof(payload.while) === undefined)
1424
- throw new Error(
1425
- "The while step needs a reviewed condition, a mandatory positive safety bound and a non-empty body in its options.",
1426
- );
1427
- if (step.kind === "foreach" && foreachof(payload.foreach) === undefined)
1428
- throw new Error(
1429
- "The foreach step needs a reviewed non-empty selector, distinct item and index variables and a non-empty body in its options.",
1430
- );
1431
- if (step.kind === "parallel" && parallelof(payload.parallel) === undefined)
1432
- throw new Error(
1433
- "The parallel step needs uniquely identified branches with bodies and a join policy of the first, last or fail strategy with cancel or continue on branch failure in its options.",
1434
- );
1435
- if (step.kind === "trycatch" && tryof(payload.try) === undefined)
1436
- throw new Error(
1437
- "The try step needs a fragile body, a catch handler and optional retry and timeout policies in its options.",
1438
- );
1439
- }
1440
-
1441
- /** Returns every child step of one control payload recursively so composition grades the risk of the whole construct and review hides no step; malformed payloads yield no hidden steps because composition refuses them first. */
1442
- export function controlsteps(step: workflowstep): workflowstep[] {
1443
- if (!iscontrolflowkind(step.kind)) return [];
1444
- let payload: Record<string, unknown>;
1445
- try {
1446
- payload = controloptions(step);
1447
- } catch {
1448
- return [];
1449
- }
1450
- const children: workflowstep[] = [];
1451
- const collect = (steps: workflowstep[]): void => {
1452
- for (const child of steps) {
1453
- children.push(child);
1454
- collect(controlsteps(child));
1455
- }
1456
- };
1457
- if (step.kind === "condition") return children;
1458
- if (step.kind === "branch") {
1459
- const branch = branchof(payload.branch);
1460
- if (!branch) return children;
1461
- for (const path of branch.paths) collect(path.steps);
1462
- collect(branch.else.steps);
1463
- return children;
1464
- }
1465
- if (step.kind === "loop") {
1466
- const loop = loopof(payload.loop);
1467
- if (loop) collect(loop.steps);
1468
- return children;
1469
- }
1470
- if (step.kind === "repeatuntil") {
1471
- const repeat = repeatuntilof(payload.repeatuntil);
1472
- if (repeat) collect(repeat.steps);
1473
- return children;
1474
- }
1475
- if (step.kind === "whileloop") {
1476
- const condition = whileof(payload.while);
1477
- if (condition) collect(condition.steps);
1478
- return children;
1479
- }
1480
- if (step.kind === "foreach") {
1481
- const foreach = foreachof(payload.foreach);
1482
- if (foreach) collect(foreach.steps);
1483
- return children;
1484
- }
1485
- if (step.kind === "parallel") {
1486
- const parallel = parallelof(payload.parallel);
1487
- if (parallel) for (const branch of parallel.branches) collect(branch.steps);
1488
- return children;
1489
- }
1490
- const fragile = tryof(payload.try);
1491
- if (fragile) {
1492
- collect(fragile.steps);
1493
- collect(fragile.catch.steps);
1494
- }
1495
- return children;
1496
- }
1497
-
1498
- /** Builds the review summary of one control step: the path names with the else path, the loop bounds and bodies, the parallel branches with the join policy and the try retry and timeout policies. */
1499
- export function controlsummary(
1500
- step: workflowstep,
1501
- ):
1502
- | {
1503
- kind: string;
1504
- paths?: string[];
1505
- elsepath?: string;
1506
- list?: string;
1507
- item?: string;
1508
- index?: string;
1509
- bound?: number;
1510
- selector?: string;
1511
- branches?: string[];
1512
- strategy?: string;
1513
- onfail?: string;
1514
- attempts?: number;
1515
- backoff?: string;
1516
- rerun?: boolean;
1517
- stepms?: number;
1518
- runms?: number;
1519
- expression?: string;
1520
- }
1521
- | undefined {
1522
- if (!iscontrolflowkind(step.kind)) return undefined;
1523
- let payload: Record<string, unknown>;
1524
- try {
1525
- payload = controloptions(step);
1526
- } catch {
1527
- return { kind: step.kind };
1528
- }
1529
- if (step.kind === "condition") {
1530
- const condition = conditionof(payload.condition);
1531
- return {
1532
- kind: step.kind,
1533
- ...(condition ? { expression: `${condition.expression.operator} into ${condition.expression.result}` } : {}),
1534
- };
1535
- }
1536
- if (step.kind === "branch") {
1537
- const branch = branchof(payload.branch);
1538
- return {
1539
- kind: step.kind,
1540
- ...(branch ? { paths: branch.paths.map((path) => path.name), elsepath: branch.else.name } : {}),
1541
- };
1542
- }
1543
- if (step.kind === "loop") {
1544
- const loop = loopof(payload.loop);
1545
- return {
1546
- kind: step.kind,
1547
- ...(loop
1548
- ? {
1549
- list: loop.list,
1550
- item: loop.item,
1551
- index: loop.index,
1552
- ...(loop.bound !== undefined ? { bound: loop.bound } : { bound: defaultloopbound }),
1553
- }
1554
- : {}),
1555
- };
1556
- }
1557
- if (step.kind === "repeatuntil") {
1558
- const repeat = repeatuntilof(payload.repeatuntil);
1559
- return { kind: step.kind, ...(repeat ? { bound: repeat.bound ?? defaultloopbound } : {}) };
1560
- }
1561
- if (step.kind === "whileloop") {
1562
- const condition = whileof(payload.while);
1563
- return { kind: step.kind, ...(condition ? { bound: condition.bound } : {}) };
1564
- }
1565
- if (step.kind === "foreach") {
1566
- const foreach = foreachof(payload.foreach);
1567
- return {
1568
- kind: step.kind,
1569
- ...(foreach ? { selector: foreach.selector, item: foreach.item, index: foreach.index } : {}),
1570
- };
1571
- }
1572
- if (step.kind === "parallel") {
1573
- const parallel = parallelof(payload.parallel);
1574
- return {
1575
- kind: step.kind,
1576
- ...(parallel
1577
- ? {
1578
- branches: parallel.branches.map((branch) => branch.id),
1579
- strategy: parallel.join.strategy,
1580
- onfail: parallel.join.onfail,
1581
- }
1582
- : {}),
1583
- };
1584
- }
1585
- const fragile = tryof(payload.try);
1586
- return {
1587
- kind: step.kind,
1588
- ...(fragile
1589
- ? {
1590
- ...(fragile.retry !== undefined
1591
- ? {
1592
- attempts: fragile.retry.attempts,
1593
- backoff: `${fragile.retry.backoff.shape} base ${fragile.retry.backoff.base} jitter ${fragile.retry.backoff.jitter}`,
1594
- }
1595
- : {}),
1596
- ...(fragile.catch.rerun === true ? { rerun: true } : {}),
1597
- ...(fragile.timeout?.stepms !== undefined ? { stepms: fragile.timeout.stepms } : {}),
1598
- ...(fragile.timeout?.runms !== undefined ? { runms: fragile.timeout.runms } : {}),
1599
- }
1600
- : {}),
1601
- };
1602
- }
1603
-
1604
- /** Evaluates one condition payload over the extracted values of the scopes; the expression must resolve to a boolean and the step keeps no page side effect. */
1605
- export function evaluatecondition(condition: conditionstep, scopes: variablescope[]): boolean {
1606
- const value = expressioneval(condition.expression, scopes);
1607
- if (typeof value !== "boolean") throw new Error("The condition expression must resolve to a boolean.");
1608
- return value;
1609
- }
1610
-
1611
- /** Chooses one branch path by page state and extracted values: the first path whose condition holds wins, an unconditional path matches always and the else path terminates every branch when no condition holds. */
1612
- export function choosebranch(input: {
1613
- stepid: string;
1614
- branch: branchstep;
1615
- scopes: variablescope[];
1616
- pagestate?: { url?: string; title?: string; ready?: boolean };
1617
- now: number;
1618
- }): { outcome: branchoutcome; steps: workflowstep[] } {
1619
- let scopes = input.scopes;
1620
- if (input.pagestate !== undefined) {
1621
- const parent = scopes.length > 0 ? (scopes[scopes.length - 1] as variablescope).name : undefined;
1622
- scopes = pushscope(scopes, `pagestate${input.stepid}`, parent);
1623
- if (input.pagestate.url !== undefined)
1624
- scopes = setvariable(scopes, "pageurl", "string", input.pagestate.url, input.now);
1625
- if (input.pagestate.title !== undefined)
1626
- scopes = setvariable(scopes, "pagetitle", "string", input.pagestate.title, input.now);
1627
- if (input.pagestate.ready !== undefined)
1628
- scopes = setvariable(scopes, "pageready", "boolean", input.pagestate.ready, input.now);
1629
- }
1630
- for (const path of input.branch.paths) {
1631
- if (path.when === undefined)
1632
- return {
1633
- outcome: {
1634
- stepid: input.stepid,
1635
- path: path.name,
1636
- reason: `The path ${path.name} matches unconditionally.`,
1637
- at: input.now,
1638
- },
1639
- steps: path.steps,
1640
- };
1641
- const value = expressioneval(path.when, scopes);
1642
- if (typeof value !== "boolean") throw new Error(`The branch path ${path.name} needs a boolean expression.`);
1643
- if (value)
1644
- return {
1645
- outcome: {
1646
- stepid: input.stepid,
1647
- path: path.name,
1648
- reason: `The condition of the path ${path.name} holds.`,
1649
- at: input.now,
1650
- },
1651
- steps: path.steps,
1652
- };
1653
- }
1654
- return {
1655
- outcome: {
1656
- stepid: input.stepid,
1657
- path: input.branch.else.name,
1658
- reason: "No path condition held and the else path ran.",
1659
- at: input.now,
1660
- },
1661
- steps: input.branch.else.steps,
1662
- };
1663
- }
1664
-
1665
- /** The result shape every control runner returns: the outcome flag, the merged scopes, the runlog entries, the summary and the control flow decision for the audit stores. */
1666
- interface controlresult {
1667
- ok: boolean;
1668
- scopes: variablescope[];
1669
- log: runlogentry[];
1670
- summary: string;
1671
- decision?: controlflowdecision;
1672
- }
1673
-
1674
- /** The context every control runner shares with the dispatcher: the optional iteration path carries the outer index trail of nested loops for the audit counters. */
1675
- interface controlcontextbase {
1676
- step: workflowstep;
1677
- scopes: variablescope[];
1678
- outputs: Record<string, stepoutcome>;
1679
- execute: controlexecute;
1680
- now: number;
1681
- path?: string;
1682
- }
1683
-
1684
- /** Runs the child steps of one control body sequentially through the step machinery: control children dispatch recursively and every child outcome lands in the runlog. */
1685
- async function runbody(
1686
- input: controlcontextbase & { steps: workflowstep[] },
1687
- ): Promise<{
1688
- ok: boolean;
1689
- scopes: variablescope[];
1690
- log: runlogentry[];
1691
- outputs: Record<string, stepoutcome>;
1692
- failure?: stepexecution;
1693
- }> {
1694
- let scopes = input.scopes;
1695
- const outputs: Record<string, stepoutcome> = { ...input.outputs };
1696
- const log: runlogentry[] = [];
1697
- for (const child of input.steps) {
1698
- if (iscontrolflowkind(child.kind)) {
1699
- const result = await runcontrolstep({
1700
- step: child,
1701
- scopes,
1702
- outputs,
1703
- execute: input.execute,
1704
- now: input.now,
1705
- ...(input.path !== undefined ? { path: `${input.path}.${child.id}` } : {}),
1706
- });
1707
- scopes = result.scopes;
1708
- log.push(...result.log);
1709
- outputs[child.id] = {
1710
- stepid: child.id,
1711
- ok: result.output.ok,
1712
- summary: result.output.summary,
1713
- ...(result.output.details !== undefined ? { details: result.output.details } : {}),
1714
- at: input.now,
1715
- };
1716
- if (!result.output.ok) return { ok: false, scopes, log, outputs, failure: result.output };
1717
- continue;
1718
- }
1719
- const executed = await runstep({ step: child, scopes, outputs, execute: input.execute, now: input.now });
1720
- scopes = executed.scopes;
1721
- if (executed.childlog !== undefined) log.push(...executed.childlog);
1722
- log.push(executed.log);
1723
- outputs[child.id] = {
1724
- stepid: child.id,
1725
- ok: executed.output.ok,
1726
- summary: executed.output.summary,
1727
- ...(executed.output.details !== undefined ? { details: executed.output.details } : {}),
1728
- at: input.now,
1729
- };
1730
- if (!executed.output.ok) return { ok: false, scopes, log, outputs, failure: executed.output };
1731
- }
1732
- return { ok: true, scopes, log, outputs };
1733
- }
1734
-
1735
- /** Deep copies one item value per iteration so no cross iteration mutation reaches the next iteration. */
1736
- function deepcopy(value: unknown): unknown {
1737
- if (Array.isArray(value)) return value.map(deepcopy);
1738
- if (value && typeof value === "object")
1739
- return Object.fromEntries(
1740
- Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, deepcopy(entry)]),
1741
- );
1742
- return value;
1743
- }
1744
-
1745
- /** Builds one iteration marker entry of a loop family runner so the runlog carries the iteration trail. */
1746
- function iterationentry(step: workflowstep, iteration: number, total: number, ok: boolean, now: number): runlogentry {
1747
- return {
1748
- stepid: step.id,
1749
- label: `${step.label} iteration ${iteration + 1}`,
1750
- state: ok ? "done" : "failed",
1751
- startedat: now,
1752
- duration: 0,
1753
- summary: `Iteration ${iteration + 1} of ${total}.`,
1754
- details: { iteration, total },
1755
- };
1756
- }
1757
-
1758
- /** Runs one loop over a data list variable: each iteration deep copies the item, rebinds the item and index variables, runs the body and records the loop counter with its iteration path for the audit stores. */
1759
- export async function runloop(input: controlcontextbase & { loop: loopstep }): Promise<controlresult> {
1760
- const list = resolvevariable(input.scopes, input.loop.list);
1761
- if (!list) throw new Error(`The loop references the undefined list variable ${input.loop.list}.`);
1762
- if (list.kind !== "list") throw new Error(`The loop variable ${input.loop.list} is not a list.`);
1763
- const items = list.value as string[];
1764
- const bound = input.loop.bound ?? defaultloopbound;
1765
- const loops: loopcounter[] = [];
1766
- const decision: controlflowdecision = { runid: "", stepid: input.step.id, kind: "loop", at: input.now, loops };
1767
- if (items.length > bound) {
1768
- return {
1769
- ok: false,
1770
- scopes: input.scopes,
1771
- log: [],
1772
- summary: `The loop list holds ${items.length} items and exceeds the reviewed safety bound of ${bound} iterations; nothing ran.`,
1773
- decision,
1774
- };
1775
- }
1776
- let scopes = input.scopes;
1777
- const log: runlogentry[] = [];
1778
- for (let index = 0; index < items.length; index += 1) {
1779
- scopes = setvariable(scopes, input.loop.item, "string", deepcopy(items[index]) as string, input.now);
1780
- scopes = setvariable(scopes, input.loop.index, "number", index, input.now);
1781
- const body = await runbody({
1782
- step: input.step,
1783
- scopes,
1784
- outputs: input.outputs,
1785
- execute: input.execute,
1786
- now: input.now,
1787
- steps: input.loop.steps,
1788
- path: `${input.path ?? input.step.id}[${index}]`,
1789
- });
1790
- scopes = body.scopes;
1791
- const ok = body.ok;
1792
- loops.push({
1793
- stepid: input.step.id,
1794
- path: `${input.path ?? input.step.id}[${index}]`,
1795
- iteration: index,
1796
- ok,
1797
- at: input.now,
1798
- });
1799
- log.push(...body.log, iterationentry(input.step, index, items.length, ok, input.now));
1800
- if (!ok)
1801
- return {
1802
- ok: false,
1803
- scopes,
1804
- log,
1805
- summary: `The loop failed at iteration ${index + 1} of ${items.length}: ${body.failure?.summary ?? "the body step failed."}`,
1806
- decision,
1807
- };
1808
- }
1809
- return {
1810
- ok: true,
1811
- scopes,
1812
- log,
1813
- summary: `The loop ran ${items.length} iteration${items.length === 1 ? "" : "s"} over ${input.loop.list} inside the reviewed safety bound of ${bound}.`,
1814
- decision,
1815
- };
1816
- }
1817
-
1818
- /** Runs one repeat until block: the body reruns until the convergence expression holds, the safety bound refuses a block that never converges and every pass records its loop counter. */
1819
- export async function runrepeatuntil(input: controlcontextbase & { repeat: repeatuntilstep }): Promise<controlresult> {
1820
- const bound = input.repeat.bound ?? defaultloopbound;
1821
- let scopes = input.scopes;
1822
- const log: runlogentry[] = [];
1823
- const loops: loopcounter[] = [];
1824
- const decision: controlflowdecision = { runid: "", stepid: input.step.id, kind: "loop", at: input.now, loops };
1825
- for (let iteration = 0; iteration < bound; iteration += 1) {
1826
- const body = await runbody({
1827
- step: input.step,
1828
- scopes,
1829
- outputs: input.outputs,
1830
- execute: input.execute,
1831
- now: input.now,
1832
- steps: input.repeat.steps,
1833
- path: `${input.path ?? input.step.id}[${iteration}]`,
1834
- });
1835
- scopes = body.scopes;
1836
- log.push(...body.log);
1837
- const converged = evaluatecondition({ expression: input.repeat.until }, scopes);
1838
- loops.push({
1839
- stepid: input.step.id,
1840
- path: `${input.path ?? input.step.id}[${iteration}]`,
1841
- iteration,
1842
- ok: body.ok,
1843
- at: input.now,
1844
- });
1845
- if (!body.ok)
1846
- return {
1847
- ok: false,
1848
- scopes,
1849
- log,
1850
- summary: `The repeat until failed at iteration ${iteration + 1}: ${body.failure?.summary ?? "the body step failed."}`,
1851
- decision,
1852
- };
1853
- log.push(iterationentry(input.step, iteration, bound, true, input.now));
1854
- if (converged)
1855
- return {
1856
- ok: true,
1857
- scopes,
1858
- log,
1859
- summary: `The repeat until converged after ${iteration + 1} iteration${iteration === 0 ? "" : "s"} inside the reviewed safety bound of ${bound}.`,
1860
- decision,
1861
- };
1862
- }
1863
- return {
1864
- ok: false,
1865
- scopes,
1866
- log,
1867
- summary: `The repeat until never converged within the reviewed safety bound of ${bound} iterations.`,
1868
- decision,
1869
- };
1870
- }
1871
-
1872
- /** Runs one while block: the condition gates every pass, the mandatory safety bound refuses an endless loop and the overflow is reported with the loop counters. */
1873
- export async function runwhile(input: controlcontextbase & { condition: whilestep }): Promise<controlresult> {
1874
- let scopes = input.scopes;
1875
- const log: runlogentry[] = [];
1876
- const loops: loopcounter[] = [];
1877
- const decision: controlflowdecision = { runid: "", stepid: input.step.id, kind: "loop", at: input.now, loops };
1878
- for (let iteration = 0; iteration < input.condition.bound; iteration += 1) {
1879
- if (!evaluatecondition({ expression: input.condition.while }, scopes)) {
1880
- return {
1881
- ok: true,
1882
- scopes,
1883
- log,
1884
- summary: `The while loop ended after ${iteration} iteration${iteration === 1 ? "" : "s"} because its condition stopped holding inside the reviewed safety bound of ${input.condition.bound}.`,
1885
- decision,
1886
- };
1887
- }
1888
- const body = await runbody({
1889
- step: input.step,
1890
- scopes,
1891
- outputs: input.outputs,
1892
- execute: input.execute,
1893
- now: input.now,
1894
- steps: input.condition.steps,
1895
- path: `${input.path ?? input.step.id}[${iteration}]`,
1896
- });
1897
- scopes = body.scopes;
1898
- log.push(...body.log);
1899
- loops.push({
1900
- stepid: input.step.id,
1901
- path: `${input.path ?? input.step.id}[${iteration}]`,
1902
- iteration,
1903
- ok: body.ok,
1904
- at: input.now,
1905
- });
1906
- if (!body.ok)
1907
- return {
1908
- ok: false,
1909
- scopes,
1910
- log,
1911
- summary: `The while loop failed at iteration ${iteration + 1}: ${body.failure?.summary ?? "the body step failed."}`,
1912
- decision,
1913
- };
1914
- log.push(iterationentry(input.step, iteration, input.condition.bound, true, input.now));
1915
- }
1916
- if (evaluatecondition({ expression: input.condition.while }, scopes)) {
1917
- return {
1918
- ok: false,
1919
- scopes,
1920
- log,
1921
- summary: `The while loop hit its reviewed safety bound of ${input.condition.bound} iterations while its condition still held; the overflow is reported instead of looping forever.`,
1922
- decision,
1923
- };
1924
- }
1925
- return {
1926
- ok: true,
1927
- scopes,
1928
- log,
1929
- summary: `The while loop ended after ${input.condition.bound} iteration${input.condition.bound === 1 ? "" : "s"} inside the reviewed safety bound.`,
1930
- decision,
1931
- };
1932
- }
1933
-
1934
- /** Runs one foreach block: the injected resolver turns the selector into element references, every reference binds as the element item with its index and the empty match is an honest zero iteration outcome. */
1935
- export async function runforeach(
1936
- input: controlcontextbase & { foreach: foreachstep; resolveelements?: (selector: string) => Promise<string[]> },
1937
- ): Promise<controlresult> {
1938
- if (!input.resolveelements) throw new Error("The foreach step needs the element resolver of the executor seam.");
1939
- const elements = await input.resolveelements(input.foreach.selector);
1940
- const loops: loopcounter[] = [];
1941
- const decision: controlflowdecision = { runid: "", stepid: input.step.id, kind: "loop", at: input.now, loops };
1942
- if (elements.length === 0)
1943
- return {
1944
- ok: true,
1945
- scopes: input.scopes,
1946
- log: [],
1947
- summary: `The selector ${input.foreach.selector} matched no element and the foreach ran zero iterations.`,
1948
- decision,
1949
- };
1950
- let scopes = input.scopes;
1951
- const log: runlogentry[] = [];
1952
- for (let index = 0; index < elements.length; index += 1) {
1953
- scopes = setvariable(scopes, input.foreach.item, "element", deepcopy(elements[index]) as string, input.now);
1954
- scopes = setvariable(scopes, input.foreach.index, "number", index, input.now);
1955
- const body = await runbody({
1956
- step: input.step,
1957
- scopes,
1958
- outputs: input.outputs,
1959
- execute: input.execute,
1960
- now: input.now,
1961
- steps: input.foreach.steps,
1962
- path: `${input.path ?? input.step.id}[${index}]`,
1963
- });
1964
- scopes = body.scopes;
1965
- const ok = body.ok;
1966
- loops.push({
1967
- stepid: input.step.id,
1968
- path: `${input.path ?? input.step.id}[${index}]`,
1969
- iteration: index,
1970
- ok,
1971
- at: input.now,
1972
- });
1973
- log.push(...body.log, iterationentry(input.step, index, elements.length, ok, input.now));
1974
- if (!ok)
1975
- return {
1976
- ok: false,
1977
- scopes,
1978
- log,
1979
- summary: `The foreach failed at iteration ${index + 1} of ${elements.length}: ${body.failure?.summary ?? "the body step failed."}`,
1980
- decision,
1981
- };
1982
- }
1983
- return {
1984
- ok: true,
1985
- scopes,
1986
- log,
1987
- summary: `The foreach ran ${elements.length} iteration${elements.length === 1 ? "" : "s"} over the elements of ${input.foreach.selector}.`,
1988
- decision,
1989
- };
1990
- }
1991
-
1992
- /** Merges the variable writes of the parallel branches under the reviewed join strategy: first or last wins by branch order and fail refuses any conflicting write; cancelled branches never contribute. */
1993
- export function joinbranches(input: {
1994
- stepid: string;
1995
- branches: Array<{ id: string; order: number; ok: boolean; cancelled: boolean; variables: variablevalue[] }>;
1996
- strategy: "first" | "last" | "fail";
1997
- now: number;
1998
- }): { ok: boolean; conflicts: string[]; merged: variablevalue[]; record: joinrecord; summary: string } {
1999
- const contributing = input.branches.filter((branch) => !branch.cancelled);
2000
- const byname = new Map<string, Array<{ order: number; value: variablevalue }>>();
2001
- for (const branch of contributing)
2002
- for (const variable of branch.variables) {
2003
- const entries = byname.get(variable.name) ?? [];
2004
- entries.push({ order: branch.order, value: variable });
2005
- byname.set(variable.name, entries);
2006
- }
2007
- const conflicts = [...byname.entries()].filter(([, entries]) => entries.length > 1).map(([name]) => name);
2008
- const record: joinrecord = { stepid: input.stepid, strategy: input.strategy, conflicts, merged: [], at: input.now };
2009
- if (conflicts.length > 0 && input.strategy === "fail") {
2010
- return {
2011
- ok: false,
2012
- conflicts,
2013
- merged: [],
2014
- record,
2015
- summary: `The join refused the conflicting writes of ${conflicts.join(", ")} under the fail strategy.`,
2016
- };
2017
- }
2018
- const merged: variablevalue[] = [];
2019
- for (const [name, entries] of byname) {
2020
- void name;
2021
- const winner =
2022
- input.strategy === "first"
2023
- ? entries.reduce((left, right) => (left.order <= right.order ? left : right))
2024
- : entries.reduce((left, right) => (left.order >= right.order ? left : right));
2025
- merged.push({ ...winner.value, setat: input.now });
2026
- }
2027
- record.merged = merged.map((variable) => variable.name);
2028
- return {
2029
- ok: true,
2030
- conflicts,
2031
- merged,
2032
- record,
2033
- summary: `The join merged ${merged.length} variable${merged.length === 1 ? "" : "s"} under the ${input.strategy} strategy${conflicts.length > 0 ? ` with the conflicts ${conflicts.join(", ")} resolved by the strategy` : " with no conflict"}.`,
2034
- };
2035
- }
2036
-
2037
- /** Runs one parallel block: every branch launches concurrently inside its own isolated scope, the join policy decides whether sibling branches cancel or continue on branch failure and the join merges the branch writes under the reviewed strategy. */
2038
- export async function runparallel(input: controlcontextbase & { parallel: parallelstep }): Promise<controlresult> {
2039
- let finished = 0;
2040
- let firstfailure = Number.POSITIVE_INFINITY;
2041
- const log: runlogentry[] = [];
2042
- const launches = input.parallel.branches.map((branch, order) =>
2043
- (async () => {
2044
- const parent =
2045
- input.scopes.length > 0 ? (input.scopes[input.scopes.length - 1] as variablescope).name : undefined;
2046
- const isolated = pushscope(input.scopes, `branch${branch.id}`, parent);
2047
- const body = await runbody({
2048
- step: input.step,
2049
- scopes: isolated,
2050
- outputs: input.outputs,
2051
- execute: input.execute,
2052
- now: input.now,
2053
- steps: branch.steps,
2054
- });
2055
- const finishedat = finished;
2056
- finished += 1;
2057
- if (!body.ok && finishedat < firstfailure) firstfailure = finishedat;
2058
- const scope = body.scopes[body.scopes.length - 1] as variablescope;
2059
- return {
2060
- branch,
2061
- order,
2062
- finishedat,
2063
- ok: body.ok,
2064
- scopes: body.scopes,
2065
- variables: scope.name === `branch${branch.id}` ? scope.variables : [],
2066
- log: body.log,
2067
- failure: body.failure,
2068
- };
2069
- })(),
2070
- );
2071
- const settled = await Promise.all(launches);
2072
- for (const entry of settled) log.push(...entry.log);
2073
- const cancelmode = input.parallel.join.onfail === "cancel";
2074
- const outcomes: paralleloutcome[] = settled.map((entry) => ({
2075
- branchid: entry.branch.id,
2076
- ok: entry.ok,
2077
- summary: entry.ok
2078
- ? `The branch ${entry.branch.id} completed.`
2079
- : (entry.failure?.summary ?? `The branch ${entry.branch.id} failed.`),
2080
- ...(cancelmode && firstfailure !== Number.POSITIVE_INFINITY && entry.finishedat > firstfailure
2081
- ? { cancelled: true }
2082
- : {}),
2083
- }));
2084
- const join = joinbranches({
2085
- stepid: input.step.id,
2086
- branches: settled.map((entry, order) => ({
2087
- id: entry.branch.id,
2088
- order,
2089
- ok: entry.ok,
2090
- cancelled: outcomes[order]?.cancelled === true,
2091
- variables: entry.variables,
2092
- })),
2093
- strategy: input.parallel.join.strategy,
2094
- now: input.now,
2095
- });
2096
- const decision: controlflowdecision = {
2097
- runid: "",
2098
- stepid: input.step.id,
2099
- kind: "join",
2100
- at: input.now,
2101
- join: join.record,
2102
- branches: outcomes,
2103
- };
2104
- if (!join.ok) return { ok: false, scopes: input.scopes, log, summary: join.summary, decision };
2105
- let scopes = input.scopes;
2106
- for (const variable of join.merged)
2107
- scopes = setvariable(scopes, variable.name, variable.kind, variable.value, input.now);
2108
- const failedbranches = settled
2109
- .filter((entry, order) => !entry.ok && outcomes[order]?.cancelled !== true)
2110
- .map((entry) => entry.branch.id);
2111
- if (cancelmode && failedbranches.length > 0) {
2112
- return {
2113
- ok: false,
2114
- scopes,
2115
- log,
2116
- summary: `The parallel block failed on branch ${failedbranches.join(", ")} and the join policy cancelled the siblings still running; ${join.summary}`,
2117
- decision,
2118
- };
2119
- }
2120
- return {
2121
- ok: true,
2122
- scopes,
2123
- log,
2124
- summary: `The parallel block ran ${input.parallel.branches.length} concurrent branch${input.parallel.branches.length === 1 ? "" : "es"}; ${join.summary}`,
2125
- decision,
2126
- };
2127
- }
2128
-
2129
- /** Returns the error class of one failed outcome: the reviewed errorclass detail when present and the honest stepfailed class otherwise. */
2130
- function errorclassof(output: { details?: Record<string, unknown> }): string {
2131
- const errorclass = output.details?.errorclass;
2132
- return typeof errorclass === "string" && errorclass.trim() ? errorclass : "stepfailed";
2133
- }
2134
-
2135
- /** Computes one retry backoff delay under the reviewed shape: fixed keeps the base and exponential doubles it per failed attempt, and the seeded jitter window spans half the jitter around it. */
2136
- export function backoffdelay(policy: retrypolicy, attempt: number, seed: number): number {
2137
- const base = policy.backoff.shape === "exponential" ? policy.backoff.base * 2 ** (attempt - 1) : policy.backoff.base;
2138
- if (policy.backoff.jitter <= 0) return Math.max(0, base);
2139
- return Math.max(0, base - policy.backoff.jitter / 2 + seededrandom(seed + attempt) * policy.backoff.jitter);
2140
- }
2141
-
2142
- /** Waits the reviewed backoff delay between retry attempts. */
2143
- function waitsome(milliseconds: number): Promise<void> {
2144
- return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
2145
- }
2146
-
2147
- /** Retries one failed step under the reviewed retry policy: the attempts stay user configured with no code ceiling, the backoff shape is fixed or exponential with seeded jitter and only the reviewed error classes retry; the attempts and their backoff durations are recorded for the audit stores. */
2148
- export async function applyretry<T extends { ok: boolean }>(input: {
2149
- stepid: string;
2150
- policy: retrypolicy;
2151
- run: () => Promise<T>;
2152
- errorclass: (value: T) => string;
2153
- now: number;
2154
- seed?: number;
2155
- }): Promise<{ value: T; attempts: retryattempt[]; exhausted: boolean }> {
2156
- const seed = input.seed ?? 0;
2157
- let value = await input.run();
2158
- const attempts: retryattempt[] = [];
2159
- let attempt = 1;
2160
- while (!value.ok && attempt < input.policy.attempts) {
2161
- const errorclass = input.errorclass(value);
2162
- if (!input.policy.retryable.includes(errorclass)) break;
2163
- const delay = backoffdelay(input.policy, attempt, seed);
2164
- attempts.push({ stepid: input.stepid, attempt: attempt + 1, delay, errorclass, at: input.now });
2165
- if (delay > 0) await waitsome(delay);
2166
- attempt += 1;
2167
- value = await input.run();
2168
- }
2169
- return { value, attempts, exhausted: !value.ok && attempts.length > 0 && attempt >= input.policy.attempts };
2170
- }
2171
-
2172
- /** Races one step against its reviewed millisecond budget: an exceeded budget cancels the wait through the executor seam and returns the cancelled outcome with the timeout error class and the exceeded budget. */
2173
- export async function applytimeout<T extends { ok: boolean }>(input: {
2174
- stepid: string;
2175
- budgetms: number;
2176
- run: () => Promise<T>;
2177
- }): Promise<{ aborted: boolean; value?: T; output?: stepexecution; abort?: timeoutabort }> {
2178
- let timer: ReturnType<typeof setTimeout> | undefined;
2179
- const guard = new Promise<"cancelled">((resolve) => {
2180
- timer = setTimeout(() => resolve("cancelled"), Math.max(0, input.budgetms));
2181
- });
2182
- const raced = await Promise.race([
2183
- input.run().then((value) => ({ kind: "done" as const, value })),
2184
- guard.then((marker) => ({ kind: "cancelled" as const, marker })),
2185
- ]);
2186
- if (timer !== undefined) clearTimeout(timer);
2187
- if (raced.kind === "done") return { aborted: false, value: raced.value };
2188
- return {
2189
- aborted: true,
2190
- output: {
2191
- ok: false,
2192
- summary: `The ${input.stepid} step exceeded its reviewed budget of ${input.budgetms} milliseconds and was cancelled.`,
2193
- details: { errorclass: "timeout", budget: input.budgetms, cancelled: true },
2194
- },
2195
- abort: { stepid: input.stepid, budget: input.budgetms, scope: "step", at: Date.now() },
2196
- };
2197
- }
2198
-
2199
- /** Races a whole run against its reviewed millisecond budget: an exceeded budget aborts the run with the cancelled error class carrying the exceeded budget. */
2200
- export async function applyruntimeout<T>(input: {
2201
- budgetms: number;
2202
- run: () => Promise<T>;
2203
- }): Promise<{ cancelled: true; error: cancellederror } | { cancelled: false; value: T }> {
2204
- let timer: ReturnType<typeof setTimeout> | undefined;
2205
- const guard = new Promise<"cancelled">((resolve) => {
2206
- timer = setTimeout(() => resolve("cancelled"), Math.max(0, input.budgetms));
2207
- });
2208
- const raced = await Promise.race([
2209
- input.run().then((value) => ({ kind: "done" as const, value })),
2210
- guard.then((marker) => ({ kind: "cancelled" as const, marker })),
2211
- ]);
2212
- if (timer !== undefined) clearTimeout(timer);
2213
- if (raced.kind === "done") return { cancelled: false, value: raced.value };
2214
- return {
2215
- cancelled: true,
2216
- error: new cancellederror(
2217
- `The run exceeded its reviewed budget of ${input.budgetms} milliseconds and was cancelled.`,
2218
- ),
2219
- };
2220
- }
2221
-
2222
- /** Executes the error handler steps of a try block on failure; the handler steps run through the same step machinery and their outcome decides the handler result. */
2223
- export async function runcatch(input: {
2224
- handler: errorhandler;
2225
- scopes: variablescope[];
2226
- outputs: Record<string, stepoutcome>;
2227
- execute: controlexecute;
2228
- now: number;
2229
- }): Promise<{ ok: boolean; scopes: variablescope[]; log: runlogentry[] }> {
2230
- const body = await runbody({
2231
- step: { id: "catch", kind: "trycatch", label: "catch handler" },
2232
- scopes: input.scopes,
2233
- outputs: input.outputs,
2234
- execute: input.execute,
2235
- now: input.now,
2236
- steps: input.handler.steps,
2237
- });
2238
- return { ok: body.ok, scopes: body.scopes, log: body.log };
2239
- }
2240
-
2241
- /** Runs one try block: the fragile body steps run under the reviewed retry policy and the per step timeout budgets, a failure runs the error handler and the rerun option executes the body once more after the handler. */
2242
- export async function runtry(input: controlcontextbase & { fragile: trystep }): Promise<controlresult> {
2243
- const timeouts: timeoutabort[] = [];
2244
- const retries: retryattempt[] = [];
2245
- const runonce = async (
2246
- child: workflowstep,
2247
- scopes: variablescope[],
2248
- ): Promise<{ ok: boolean; scopes: variablescope[]; log: runlogentry[]; output: stepexecution }> => {
2249
- if (iscontrolflowkind(child.kind)) {
2250
- const result = await runcontrolstep({
2251
- step: child,
2252
- scopes,
2253
- outputs: input.outputs,
2254
- execute: input.execute,
2255
- now: input.now,
2256
- });
2257
- return { ok: result.output.ok, scopes: result.scopes, log: result.log, output: result.output };
2258
- }
2259
- const executed = await runstep({
2260
- step: child,
2261
- scopes,
2262
- outputs: input.outputs,
2263
- execute: input.execute,
2264
- now: input.now,
2265
- });
2266
- return {
2267
- ok: executed.output.ok,
2268
- scopes: executed.scopes,
2269
- log: [...(executed.childlog ?? []), executed.log],
2270
- output: executed.output,
2271
- };
2272
- };
2273
- const runchild = async (
2274
- child: workflowstep,
2275
- scopes: variablescope[],
2276
- ): Promise<{ ok: boolean; scopes: variablescope[]; log: runlogentry[]; output: stepexecution }> => {
2277
- const attempt = async (): Promise<{
2278
- ok: boolean;
2279
- scopes: variablescope[];
2280
- log: runlogentry[];
2281
- output: stepexecution;
2282
- }> => {
2283
- if (input.fragile.retry === undefined) return await runonce(child, scopes);
2284
- const retried = await applyretry({
2285
- stepid: child.id,
2286
- policy: input.fragile.retry,
2287
- run: () => runonce(child, scopes),
2288
- errorclass: (value) => errorclassof(value.output),
2289
- now: input.now,
2290
- seed: seedof(child.id),
2291
- });
2292
- retries.push(...retried.attempts);
2293
- return retried.value;
2294
- };
2295
- if (input.fragile.timeout?.stepms === undefined) return await attempt();
2296
- const guarded = await applytimeout({ stepid: child.id, budgetms: input.fragile.timeout.stepms, run: attempt });
2297
- if (!guarded.aborted)
2298
- return guarded.value as { ok: boolean; scopes: variablescope[]; log: runlogentry[]; output: stepexecution };
2299
- if (guarded.abort) timeouts.push(guarded.abort);
2300
- return { ok: false, scopes, log: [], output: guarded.output as stepexecution };
2301
- };
2302
- const runbodyof = async (
2303
- scopes: variablescope[],
2304
- ): Promise<{ ok: boolean; scopes: variablescope[]; log: runlogentry[]; failure?: stepexecution }> => {
2305
- let current = scopes;
2306
- const log: runlogentry[] = [];
2307
- for (const child of input.fragile.steps) {
2308
- const executed = await runchild(child, current);
2309
- current = executed.scopes;
2310
- log.push(...executed.log);
2311
- if (!executed.ok) return { ok: false, scopes: current, log, failure: executed.output };
2312
- }
2313
- return { ok: true, scopes: current, log };
2314
- };
2315
- let body: { ok: boolean; scopes: variablescope[]; log: runlogentry[]; failure?: stepexecution };
2316
- if (input.fragile.timeout?.runms !== undefined) {
2317
- const guarded = await applytimeout({
2318
- stepid: input.step.id,
2319
- budgetms: input.fragile.timeout.runms,
2320
- run: () => runbodyof(input.scopes),
2321
- });
2322
- if (guarded.aborted) {
2323
- if (guarded.abort) timeouts.push({ ...guarded.abort, scope: "run" });
2324
- body = {
2325
- ok: false,
2326
- scopes: input.scopes,
2327
- log: [],
2328
- failure: guarded.output ?? {
2329
- ok: false,
2330
- summary: "The try block exceeded its reviewed run budget and was cancelled.",
2331
- details: { errorclass: "timeout", cancelled: true },
2332
- },
2333
- };
2334
- } else {
2335
- body = guarded.value as { ok: boolean; scopes: variablescope[]; log: runlogentry[]; failure?: stepexecution };
2336
- }
2337
- } else {
2338
- body = await runbodyof(input.scopes);
2339
- }
2340
- if (body.ok) {
2341
- const summary = `The try block completed its ${input.fragile.steps.length} step${input.fragile.steps.length === 1 ? "" : "s"}${retries.length > 0 ? ` after ${retries.length} retry attempt${retries.length === 1 ? "" : "s"}` : ""}.`;
2342
- if (retries.length === 0 && timeouts.length === 0) return { ok: true, scopes: body.scopes, log: body.log, summary };
2343
- return {
2344
- ok: true,
2345
- scopes: body.scopes,
2346
- log: body.log,
2347
- summary,
2348
- decision: {
2349
- runid: "",
2350
- stepid: input.step.id,
2351
- kind: "retry",
2352
- at: input.now,
2353
- ...(retries.length > 0 ? { retries } : {}),
2354
- ...(timeouts.length > 0 ? { timeouts } : {}),
2355
- },
2356
- };
2357
- }
2358
- const handler = await runcatch({
2359
- handler: input.fragile.catch,
2360
- scopes: body.scopes,
2361
- outputs: input.outputs,
2362
- execute: input.execute,
2363
- now: input.now,
2364
- });
2365
- const errorclass = errorclassof(body.failure ?? { ok: false, summary: "" });
2366
- const decision: controlflowdecision = {
2367
- runid: "",
2368
- stepid: input.step.id,
2369
- kind: "catch",
2370
- at: input.now,
2371
- ...(retries.length > 0 ? { retries } : {}),
2372
- ...(timeouts.length > 0 ? { timeouts } : {}),
2373
- catch: {
2374
- errorclass,
2375
- message: body.failure?.summary ?? "The fragile body step failed.",
2376
- rerun: input.fragile.catch.rerun === true,
2377
- },
2378
- };
2379
- if (!handler.ok)
2380
- return {
2381
- ok: false,
2382
- scopes: handler.scopes,
2383
- log: [...body.log, ...handler.log],
2384
- summary: `The catch handler of the try block failed after the ${errorclass} failure.`,
2385
- decision,
2386
- };
2387
- if (input.fragile.catch.rerun === true) {
2388
- const rerun = await runbodyof(handler.scopes);
2389
- if (rerun.ok)
2390
- return {
2391
- ok: true,
2392
- scopes: rerun.scopes,
2393
- log: [...body.log, ...handler.log, ...rerun.log],
2394
- summary: `The catch handler ran after the ${errorclass} failure and the rerun of the try body succeeded.`,
2395
- decision,
2396
- };
2397
- return {
2398
- ok: false,
2399
- scopes: rerun.scopes,
2400
- log: [...body.log, ...handler.log, ...rerun.log],
2401
- summary: `The catch handler ran and the rerun of the try body failed again with ${errorclassof(rerun.failure ?? { ok: false, summary: "" })}.`,
2402
- decision,
2403
- };
2404
- }
2405
- return {
2406
- ok: true,
2407
- scopes: handler.scopes,
2408
- log: [...body.log, ...handler.log],
2409
- summary: `The catch handler ran ${input.fragile.catch.steps.length} step${input.fragile.catch.steps.length === 1 ? "" : "s"} after the ${errorclass} failure.`,
2410
- decision,
2411
- };
2412
- }
2413
-
2414
- /** Deterministic seed of one step id so retry backoff windows replay exactly for audits. */
2415
- function seedof(text: string): number {
2416
- let hash = 0x811c9dc5;
2417
- for (let index = 0; index < text.length; index += 1) {
2418
- hash ^= text.charCodeAt(index);
2419
- hash = Math.imul(hash, 0x01000193) >>> 0;
2420
- }
2421
- return hash >>> 0;
2422
- }
2423
-
2424
- /** Executes exactly one control flow step: parses the reviewed payload, dispatches to the matching runner, returns the merged scopes, the iteration runlog and the outcome whose details carry the control flow decision for the audit stores. */
2425
- export async function runcontrolstep(
2426
- input: controlcontextbase & {
2427
- runid?: string;
2428
- pagestate?: { url?: string; title?: string; ready?: boolean };
2429
- resolveelements?: (selector: string) => Promise<string[]>;
2430
- },
2431
- ): Promise<{ scopes: variablescope[]; log: runlogentry[]; output: stepexecution }> {
2432
- const payload = controloptions(input.step);
2433
- const base = {
2434
- step: input.step,
2435
- scopes: input.scopes,
2436
- outputs: input.outputs,
2437
- execute: input.execute,
2438
- now: input.now,
2439
- ...(input.path !== undefined ? { path: input.path } : {}),
2440
- };
2441
- let result: controlresult;
2442
- switch (input.step.kind) {
2443
- case "condition": {
2444
- const condition = conditionof(payload.condition);
2445
- if (!condition) throw new Error("The condition step needs a reviewed boolean expression in its options.");
2446
- const value = evaluatecondition(condition, input.scopes);
2447
- const scopes = setvariable(
2448
- input.scopes,
2449
- condition.expression.result,
2450
- condition.expression.resultkind,
2451
- value,
2452
- input.now,
2453
- );
2454
- const summary = `The condition ${condition.expression.result} ${value ? "holds" : "does not hold"} over the extracted values.`;
2455
- return {
2456
- scopes,
2457
- log: [],
2458
- output: { ok: true, summary, details: { condition: { result: condition.expression.result, value } } },
2459
- };
2460
- }
2461
- case "branch": {
2462
- const branch = branchof(payload.branch);
2463
- if (!branch)
2464
- throw new Error(
2465
- "The branch step needs reviewed unique paths with boolean match expressions and an else path in its options.",
2466
- );
2467
- const chosen = choosebranch({
2468
- stepid: input.step.id,
2469
- branch,
2470
- scopes: input.scopes,
2471
- ...(input.pagestate !== undefined ? { pagestate: input.pagestate } : {}),
2472
- now: input.now,
2473
- });
2474
- const body = await runbody({ ...base, steps: chosen.steps });
2475
- result = body.ok
2476
- ? {
2477
- ok: true,
2478
- scopes: body.scopes,
2479
- log: body.log,
2480
- summary: `The branch chose the path ${chosen.outcome.path}: ${chosen.outcome.reason}`,
2481
- decision: {
2482
- runid: input.runid ?? "",
2483
- stepid: input.step.id,
2484
- kind: "branch",
2485
- at: input.now,
2486
- branch: chosen.outcome,
2487
- },
2488
- }
2489
- : {
2490
- ok: false,
2491
- scopes: body.scopes,
2492
- log: body.log,
2493
- summary: `The branch chose the path ${chosen.outcome.path} and its body failed: ${body.failure?.summary ?? "the body step failed."}`,
2494
- decision: {
2495
- runid: input.runid ?? "",
2496
- stepid: input.step.id,
2497
- kind: "branch",
2498
- at: input.now,
2499
- branch: chosen.outcome,
2500
- },
2501
- };
2502
- break;
2503
- }
2504
- case "loop": {
2505
- const loop = loopof(payload.loop);
2506
- if (!loop)
2507
- throw new Error(
2508
- "The loop step needs a reviewed list variable, distinct item and index variables, an optional positive safety bound and a non-empty body in its options.",
2509
- );
2510
- result = await runloop({ ...base, loop });
2511
- break;
2512
- }
2513
- case "repeatuntil": {
2514
- const repeat = repeatuntilof(payload.repeatuntil);
2515
- if (!repeat)
2516
- throw new Error(
2517
- "The repeat until step needs a reviewed convergence expression, an optional positive safety bound and a non-empty body in its options.",
2518
- );
2519
- result = await runrepeatuntil({ ...base, repeat });
2520
- break;
2521
- }
2522
- case "whileloop": {
2523
- const condition = whileof(payload.while);
2524
- if (!condition)
2525
- throw new Error(
2526
- "The while step needs a reviewed condition, a mandatory positive safety bound and a non-empty body in its options.",
2527
- );
2528
- result = await runwhile({ ...base, condition });
2529
- break;
2530
- }
2531
- case "foreach": {
2532
- const foreach = foreachof(payload.foreach);
2533
- if (!foreach)
2534
- throw new Error(
2535
- "The foreach step needs a reviewed non-empty selector, distinct item and index variables and a non-empty body in its options.",
2536
- );
2537
- result = await runforeach({
2538
- ...base,
2539
- foreach,
2540
- ...(input.resolveelements !== undefined ? { resolveelements: input.resolveelements } : {}),
2541
- });
2542
- break;
2543
- }
2544
- case "parallel": {
2545
- const parallel = parallelof(payload.parallel);
2546
- if (!parallel)
2547
- throw new Error(
2548
- "The parallel step needs uniquely identified branches with bodies and a join policy of the first, last or fail strategy with cancel or continue on branch failure in its options.",
2549
- );
2550
- result = await runparallel({ ...base, parallel });
2551
- break;
2552
- }
2553
- case "trycatch": {
2554
- const fragile = tryof(payload.try);
2555
- if (!fragile)
2556
- throw new Error(
2557
- "The try step needs a fragile body, a catch handler and optional retry and timeout policies in its options.",
2558
- );
2559
- result = await runtry({ ...base, fragile });
2560
- break;
2561
- }
2562
- default:
2563
- throw new Error(`The ${input.step.kind} step is not a control flow kind.`);
2564
- }
2565
- if (result.decision !== undefined && input.runid !== undefined) result.decision.runid = input.runid;
2566
- return {
2567
- scopes: result.scopes,
2568
- log: result.log,
2569
- output: {
2570
- ok: result.ok,
2571
- summary: result.summary,
2572
- ...(result.decision !== undefined ? { details: { control: result.decision } } : {}),
2573
- },
2574
- };
2575
- }
2576
-
2577
- /** Wraps one executor seam so control flow kinds dispatch to the control engine while every other kind keeps its executor: the wrapper carries the isolated run id, the page state accessor and the element resolver of the background executors, and the control outcome returns the merged scopes with its iteration runlog so the run loop adopts them. */
2578
- export function controlexecutor(
2579
- execute: controlexecute,
2580
- extras: {
2581
- runid?: string;
2582
- pagestate?: { url?: string; title?: string; ready?: boolean };
2583
- resolveelements?: (selector: string) => Promise<string[]>;
2584
- } = {},
2585
- ): controlexecute {
2586
- return async (step, context) => {
2587
- if (!iscontrolflowkind(step.kind)) return execute(step, context);
2588
- const result = await runcontrolstep({
2589
- step,
2590
- scopes: context.scopes,
2591
- outputs: context.outputs ?? {},
2592
- execute,
2593
- now: Date.now(),
2594
- ...(extras.runid !== undefined ? { runid: extras.runid } : {}),
2595
- ...(extras.pagestate !== undefined ? { pagestate: extras.pagestate } : {}),
2596
- ...(extras.resolveelements !== undefined ? { resolveelements: extras.resolveelements } : {}),
2597
- });
2598
- return { ...result.output, scopes: result.scopes, log: result.log };
2599
- };
2600
- }
2601
-
2602
- /* ── Merged from trigger.ts ── */
2603
-
2604
- /**
2605
- * Trigger and scheduling engine for the 1.1.52 family.
2606
- * Every correlated rule of the trigger phase lives in this file: the trigger family and kind lists, the family payload normalizers, the five field cron parser with named weekdays and months and its timezone aware next fire computation, the interval scheduler that spreads repeated fires with the configured seeded jitter, the glob url matcher with ports, the visit origin matcher, the cooldown suppressor, the dedupe and queue discipline that keeps one pending fire per rule while a run is active, the url list planner that starts one run per url, the webhook verifier of the shared secret and payload schema, the observed event catalog matcher, the pause and resume suspension with the resume drain, the trigger evaluation that skips disabled, paused, unreviewed or cooled down rules, the manual run step preview with its confirmation outcome and the rule summaries the review panel renders.
2607
- * The engine stays pure: the launch of a run flows through the injected launch seam so tests run on plain fixtures, and no trigger fires outside the consent gates — every rule is reviewed before it arms and every launch re-passes the session, plan and origin gates.
2608
- */
2609
-
2610
- /** The trigger kinds of the 1.1.52 family: one kind per trigger rule family, each arming its rule behind the explicit arm review. */
2611
- export const triggerkinds: string[] = [
2612
- "visitrule",
2613
- "urlrule",
2614
- "menurule",
2615
- "keyrule",
2616
- "buttonrule",
2617
- "cronrule",
2618
- "intervalrule",
2619
- "urllistrule",
2620
- "webhookrule",
2621
- "eventrule",
2622
- ];
2623
-
2624
- /** The trigger rule families the engine matches, schedules and observes. */
2625
- export const triggerfamilies: triggerfamily[] = [
2626
- "visit",
2627
- "url",
2628
- "menu",
2629
- "key",
2630
- "button",
2631
- "cron",
2632
- "interval",
2633
- "urllist",
2634
- "webhook",
2635
- "event",
2636
- ];
2637
-
2638
- /** The observed page event catalog event rules subscribe to: the mutation, focus, banner, console, error and navigation observations the extension already watches. */
2639
- export const triggereventcatalog: string[] = ["mutate", "focus", "banner", "console", "error", "navigate"];
2640
-
2641
- /** The documented default cooldown window of the webhook and event families when the review configures none; it is a documented default, never a cap, and any user configured window wins. */
2642
- export const defaulttriggercooldown = 10_000;
2643
-
2644
- /** True when the kind belongs to the trigger family of the 1.1.52 release. */
2645
- export function istriggerkind(kind: string): boolean {
2646
- return triggerkinds.includes(kind);
2647
- }
2648
-
2649
- /** Maps one trigger action kind to its rule family. */
2650
- export function triggerfamilyof(kind: string): triggerfamily | undefined {
2651
- const index = triggerkinds.indexOf(kind);
2652
- return index >= 0 ? triggerfamilies[index] : undefined;
2653
- }
2654
-
2655
- /** Normalizes one trigger label: a non-empty human readable string. */
2656
- function triggerlabel(value: unknown): string | undefined {
2657
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
2658
- }
2659
-
2660
- /** Normalizes one positive number of milliseconds; zero is refused so a configured window always means a window. */
2661
- function positivewindow(value: unknown): number | undefined {
2662
- if (value === undefined) return undefined;
2663
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
2664
- }
2665
-
2666
- /** Normalizes one optional zero or positive jitter window. */
2667
- function jitterwindow(value: unknown): number | undefined {
2668
- if (value === undefined) return undefined;
2669
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
2670
- }
2671
-
2672
- /** Normalizes one HTTPS origin string; the origin form replaces whatever url shape the review typed. */
2673
- function httpsorigin(value: unknown): string | undefined {
2674
- if (typeof value !== "string" || !value.trim()) return undefined;
2675
- try {
2676
- const parsed = new URL(value.trim());
2677
- if (parsed.protocol !== "https:") return undefined;
2678
- return parsed.origin;
2679
- } catch {
2680
- return undefined;
2681
- }
2682
- }
2683
-
2684
- /** Normalizes one webhook payload schema field: the name, the primitive kind and the required flag. */
2685
- function webhookfieldof(value: unknown): webhookfield | undefined {
2686
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2687
- const candidate = value as Record<string, unknown>;
2688
- if (typeof candidate.name !== "string" || !/^[a-z][a-z0-9]*$/i.test(candidate.name)) return undefined;
2689
- if (candidate.kind !== "string" && candidate.kind !== "number" && candidate.kind !== "boolean") return undefined;
2690
- if (candidate.required !== undefined && typeof candidate.required !== "boolean") return undefined;
2691
- return { name: candidate.name, kind: candidate.kind, ...(candidate.required === true ? { required: true } : {}) };
2692
- }
2693
-
2694
- /** Normalizes one family payload of a trigger rule and returns the family specific fields; the shared workflowid, cooldown and review flag stay with the caller. */
2695
- export function triggerpayloadof(family: triggerfamily, value: unknown): Partial<triggerule> | undefined {
2696
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2697
- const candidate = value as Record<string, unknown>;
2698
- if (family === "visit") {
2699
- if (!Array.isArray(candidate.origins) || candidate.origins.length === 0) return undefined;
2700
- const origins = candidate.origins.map((origin) => httpsorigin(origin));
2701
- if (origins.some((origin) => origin === undefined)) return undefined;
2702
- return { origins: [...new Set(origins as string[])] };
2703
- }
2704
- if (family === "url") {
2705
- if (typeof candidate.pattern !== "string" || !candidate.pattern.trim()) return undefined;
2706
- if (httpsorigin(candidate.pattern) === undefined) return undefined;
2707
- return { pattern: candidate.pattern.trim() };
2708
- }
2709
- if (family === "menu") {
2710
- const title = triggerlabel(candidate.title);
2711
- if (!title) return undefined;
2712
- return { title };
2713
- }
2714
- if (family === "key") {
2715
- if (typeof candidate.command !== "string" || !/^[a-z][a-z0-9-]*$/.test(candidate.command)) return undefined;
2716
- if (candidate.key !== undefined && (typeof candidate.key !== "string" || !candidate.key.trim())) return undefined;
2717
- return { command: candidate.command, ...(candidate.key !== undefined ? { key: candidate.key } : {}) };
2718
- }
2719
- if (family === "button") return {};
2720
- if (family === "cron") {
2721
- if (typeof candidate.cron !== "string" || !candidate.cron.trim()) return undefined;
2722
- if (cronparse(candidate.cron) === undefined) return undefined;
2723
- if (
2724
- candidate.timezone !== undefined &&
2725
- (typeof candidate.timezone !== "string" || !timezonevalid(candidate.timezone))
2726
- )
2727
- return undefined;
2728
- return {
2729
- cron: candidate.cron.trim(),
2730
- ...(candidate.timezone !== undefined ? { timezone: candidate.timezone } : {}),
2731
- };
2732
- }
2733
- if (family === "interval") {
2734
- const period = positivewindow(candidate.period);
2735
- if (period === undefined) return undefined;
2736
- const jitter = jitterwindow(candidate.jitter);
2737
- if (candidate.jitter !== undefined && jitter === undefined) return undefined;
2738
- return { period, ...(jitter !== undefined ? { jitter } : {}) };
2739
- }
2740
- if (family === "urllist") {
2741
- if (!Array.isArray(candidate.urls) || candidate.urls.length === 0) return undefined;
2742
- const urls = candidate.urls.map((url) => (httpsorigin(url) === undefined ? undefined : url.trim()));
2743
- if (urls.some((url) => url === undefined)) return undefined;
2744
- return { urls: urls as string[] };
2745
- }
2746
- if (family === "webhook") {
2747
- if (typeof candidate.secret !== "string" || !webhooksecretok(candidate.secret)) return undefined;
2748
- if (!Array.isArray(candidate.schema) || candidate.schema.length === 0) return undefined;
2749
- const schema = candidate.schema.map((field) => webhookfieldof(field));
2750
- if (schema.some((field) => field === undefined)) return undefined;
2751
- const names = (schema as webhookfield[]).map((field) => field.name);
2752
- if (new Set(names).size !== names.length) return undefined;
2753
- return { secret: candidate.secret, schema: schema as webhookfield[] };
2754
- }
2755
- const events = candidate.events;
2756
- if (!Array.isArray(events) || events.length === 0) return undefined;
2757
- if (!events.every((name) => typeof name === "string" && triggereventcatalog.includes(name))) return undefined;
2758
- return { events: [...new Set(events as string[])] };
2759
- }
2760
-
2761
- /** True when a webhook shared secret clears the documented entropy floor: at least twenty four characters mixing letters and digits; the floor is a floor, not a cap, and any longer secret wins. */
2762
- export function webhooksecretok(secret: string): boolean {
2763
- if (secret.length < 24) return false;
2764
- if (/^(.)\1+$/.test(secret)) return false;
2765
- return /[a-z]/i.test(secret) && /\d/.test(secret);
2766
- }
2767
-
2768
- /** True when the timezone name resolves through the runtime timezone database. */
2769
- export function timezonevalid(timezone: string): boolean {
2770
- try {
2771
- new Intl.DateTimeFormat("en-US", { timeZone: timezone });
2772
- return true;
2773
- } catch {
2774
- return false;
2775
- }
2776
- }
2777
-
2778
- /** Builds one armed trigger rule from the reviewed arm payload: the family normalizer validates the match fields, the effective cooldown applies the documented default of the webhook and event families and the rule starts enabled with zeroed counters. */
2779
- export function armrule(input: {
2780
- id?: string;
2781
- family: triggerfamily;
2782
- workflowid: string;
2783
- label?: string;
2784
- payload: unknown;
2785
- cooldown?: number;
2786
- now: number;
2787
- }): triggerule | undefined {
2788
- if (typeof input.workflowid !== "string" || !input.workflowid.trim()) return undefined;
2789
- const payload = triggerpayloadof(input.family, input.payload);
2790
- if (!payload) return undefined;
2791
- if (
2792
- input.cooldown !== undefined &&
2793
- (typeof input.cooldown !== "number" || !Number.isFinite(input.cooldown) || input.cooldown <= 0)
2794
- )
2795
- return undefined;
2796
- const cooldown =
2797
- input.cooldown ?? (input.family === "webhook" || input.family === "event" ? defaulttriggercooldown : 0);
2798
- const label = input.label ?? `The ${input.family} rule of ${input.workflowid}`;
2799
- return {
2800
- id: input.id ?? crypto.randomUUID(),
2801
- kind: input.family,
2802
- workflowid: input.workflowid,
2803
- label,
2804
- ...payload,
2805
- cooldown,
2806
- state: { enabled: true, cooldown },
2807
- stats: { fires: 0, launches: 0, suppressions: 0 },
2808
- createdat: input.now,
2809
- };
2810
- }
2811
-
2812
- /** The shared state of one rule update so enable, disable, pause, resume and fire bookkeeping stay immutable. */
2813
- export function updaterule(
2814
- rule: triggerule,
2815
- patch: { state?: Partial<triggerstate>; stats?: Partial<rulestats> },
2816
- ): triggerule {
2817
- return {
2818
- ...rule,
2819
- ...(patch.state !== undefined ? { state: { ...rule.state, ...patch.state } } : {}),
2820
- ...(patch.stats !== undefined ? { stats: { ...rule.stats, ...patch.stats } } : {}),
2821
- };
2822
- }
2823
-
2824
- /** Matches one glob url pattern against one navigation url: the schemes and hostnames must agree, an explicit pattern port must match while an absent port matches any port, and the path plus query glob accepts `*` inside one segment and `**` across segments. */
2825
- export function matchurl(pattern: string, url: string): boolean {
2826
- let parsedpattern: URL;
2827
- let parsedurl: URL;
2828
- try {
2829
- parsedpattern = new URL(pattern);
2830
- parsedurl = new URL(url);
2831
- } catch {
2832
- return false;
2833
- }
2834
- if (parsedpattern.protocol !== parsedurl.protocol) return false;
2835
- if (parsedpattern.hostname !== parsedurl.hostname) return false;
2836
- if (parsedpattern.port !== "" && parsedpattern.port !== parsedurl.port) return false;
2837
- return globmatch(`${parsedpattern.pathname}${parsedpattern.search}`, `${parsedurl.pathname}${parsedurl.search}`);
2838
- }
2839
-
2840
- /** Matches one glob text where `*` spans no slash and `**` spans anything. */
2841
- function globmatch(pattern: string, text: string): boolean {
2842
- const escaped = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2843
- const expression = new RegExp(
2844
- [
2845
- "^",
2846
- pattern
2847
- .split("**")
2848
- .map((segment) => segment.split("*").map(escaped).join("[^/]*"))
2849
- .join(".*"),
2850
- "$",
2851
- ].join(""),
2852
- );
2853
- return expression.test(text);
2854
- }
2855
-
2856
- /** True when a navigation url lands on one of the reviewed origins of a visit rule. */
2857
- export function visitmatch(origins: string[], url: string): boolean {
2858
- let origin = "";
2859
- try {
2860
- origin = new URL(url).origin;
2861
- } catch {
2862
- return false;
2863
- }
2864
- return origins.includes(origin);
2865
- }
2866
-
2867
- /** The five field cron parser: minute, hour, day of month, month and day of week accept `*`, lists, ranges and steps, and named weekdays (sun to sat) and months (jan to dec); day of week accepts zero and seven as sunday. */
2868
- export function cronparse(
2869
- expression: string,
2870
- ): { minutes: number[]; hours: number[]; daysofmonth: number[]; months: number[]; daysofweek: number[] } | undefined {
2871
- const fields = expression.trim().split(/\s+/);
2872
- if (fields.length !== 5) return undefined;
2873
- const minutes = cronfield(fields[0] ?? "", 0, 59);
2874
- const hours = cronfield(fields[1] ?? "", 0, 23);
2875
- const daysofmonth = cronfield(fields[2] ?? "", 1, 31);
2876
- const months = cronfield(fields[3] ?? "", 1, 12, monthnames);
2877
- const daysofweek = cronfield(fields[4] ?? "", 0, 7, weekdaynames, true);
2878
- if (!minutes || !hours || !daysofmonth || !months || !daysofweek) return undefined;
2879
- return {
2880
- minutes,
2881
- hours,
2882
- daysofmonth,
2883
- months,
2884
- daysofweek: [...new Set(daysofweek.map((day) => day % 7))].sort((left, right) => left - right),
2885
- };
2886
- }
2887
-
2888
- /** The named weekday map of the cron grammar. */
2889
- const weekdaynames: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
2890
-
2891
- /** The named month map of the cron grammar. */
2892
- const monthnames: Record<string, number> = {
2893
- jan: 1,
2894
- feb: 2,
2895
- mar: 3,
2896
- apr: 4,
2897
- may: 5,
2898
- jun: 6,
2899
- jul: 7,
2900
- aug: 8,
2901
- sep: 9,
2902
- oct: 10,
2903
- nov: 11,
2904
- dec: 12,
2905
- };
2906
-
2907
- /** Parses one cron field into its sorted value list; the star, single values, ranges, steps and comma lists are accepted and names map through their table. */
2908
- function cronfield(
2909
- field: string,
2910
- min: number,
2911
- max: number,
2912
- names?: Record<string, number>,
2913
- sundayseven = false,
2914
- ): number[] | undefined {
2915
- const values = new Set<number>();
2916
- for (const part of field.split(",")) {
2917
- if (!part) return undefined;
2918
- const [range, stepstring] = part.split("/");
2919
- const step = stepstring === undefined ? 1 : Number(stepstring);
2920
- if (!Number.isInteger(step) || step < 1) return undefined;
2921
- let low = min;
2922
- let high = max;
2923
- if (range !== undefined && range !== "*") {
2924
- const bounds = range.split("-");
2925
- if (bounds.length > 2) return undefined;
2926
- const lowvalue = cronvalue(bounds[0] ?? "", min, max, names);
2927
- if (lowvalue === undefined) return undefined;
2928
- low = lowvalue;
2929
- high = lowvalue;
2930
- if (bounds.length === 2) {
2931
- const highvalue = cronvalue(bounds[1] ?? "", min, max, names);
2932
- if (highvalue === undefined || highvalue < lowvalue) return undefined;
2933
- high = highvalue;
2934
- }
2935
- }
2936
- for (let value = low; value <= high; value += step) values.add(value);
2937
- }
2938
- const list = [...values];
2939
- if (list.some((value) => value < min || value > max)) return undefined;
2940
- if (sundayseven && values.has(7)) {
2941
- values.delete(7);
2942
- values.add(0);
2943
- }
2944
- return [...values].sort((left, right) => left - right);
2945
- }
2946
-
2947
- /** Parses one cron value: a number inside the bounds or a name of the table. */
2948
- function cronvalue(value: string, min: number, max: number, names?: Record<string, number>): number | undefined {
2949
- const candidate = names?.[value.toLowerCase()];
2950
- if (candidate !== undefined) return candidate;
2951
- if (!/^\d+$/.test(value)) return undefined;
2952
- const parsed = Number(value);
2953
- if (parsed < min || parsed > max) return undefined;
2954
- return parsed;
2955
- }
2956
-
2957
- /** The calendar parts one cron match reads: minute, hour, day of month, one based month and zero based weekday. */
2958
- interface calendarparts {
2959
- minute: number;
2960
- hour: number;
2961
- day: number;
2962
- month: number;
2963
- weekday: number;
2964
- }
2965
-
2966
- /** Reads the calendar parts of one timestamp in UTC or the reviewed timezone through the runtime timezone database. */
2967
- function calendarparts(at: number, timezone?: string): calendarparts {
2968
- if (timezone === undefined) {
2969
- const date = new Date(at);
2970
- return {
2971
- minute: date.getUTCMinutes(),
2972
- hour: date.getUTCHours(),
2973
- day: date.getUTCDate(),
2974
- month: date.getUTCMonth() + 1,
2975
- weekday: date.getUTCDay(),
2976
- };
2977
- }
2978
- const parts = new Intl.DateTimeFormat("en-US", {
2979
- timeZone: timezone,
2980
- hourCycle: "h23",
2981
- minute: "numeric",
2982
- hour: "numeric",
2983
- day: "numeric",
2984
- month: "short",
2985
- weekday: "short",
2986
- }).formatToParts(new Date(at));
2987
- const pick = (type: string): string => parts.find((part) => part.type === type)?.value ?? "";
2988
- const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(pick("weekday"));
2989
- const month =
2990
- ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(pick("month")) + 1;
2991
- return { minute: Number(pick("minute")), hour: Number(pick("hour")), day: Number(pick("day")), month, weekday };
2992
- }
2993
-
2994
- /** True when the calendar parts satisfy the day of month, month and day of week fields with the classic cron semantics: when both day fields are restricted either one matches, otherwise the restricted one must match. */
2995
- function crondaymatch(
2996
- schedule: { daysofmonth: number[]; months: number[]; daysofweek: number[] },
2997
- parts: calendarparts,
2998
- ): boolean {
2999
- if (!schedule.months.includes(parts.month)) return false;
3000
- const domfull = schedule.daysofmonth.length === 31;
3001
- const dowfull = schedule.daysofweek.length === 7;
3002
- const dommatch = schedule.daysofmonth.includes(parts.day);
3003
- const dowmatch = schedule.daysofweek.includes(parts.weekday);
3004
- if (!domfull && !dowfull) return dommatch || dowmatch;
3005
- if (!domfull) return dommatch;
3006
- if (!dowfull) return dowmatch;
3007
- return true;
3008
- }
3009
-
3010
- /** Computes the next fire time of a five field cron expression strictly after the given time: minute boundaries in UTC or the reviewed timezone; a schedule that never fires inside the four year horizon returns undefined so the honest no fire outcome travels on. */
3011
- export function cronnext(expression: string, from: number, timezone?: string): number | undefined {
3012
- const schedule = cronparse(expression);
3013
- if (!schedule) return undefined;
3014
- const minute = 60_000;
3015
- const hour = 60 * minute;
3016
- const day = 24 * hour;
3017
- let candidate = Math.floor(from / minute) * minute + minute;
3018
- const horizon = from + 4 * 366 * day;
3019
- while (candidate <= horizon) {
3020
- const parts = calendarparts(candidate, timezone);
3021
- if (!crondaymatch(schedule, parts)) {
3022
- candidate += day - parts.hour * hour - parts.minute * minute;
3023
- continue;
3024
- }
3025
- if (!schedule.hours.includes(parts.hour)) {
3026
- const later = schedule.hours.find((value) => value > parts.hour);
3027
- candidate +=
3028
- later === undefined
3029
- ? day - parts.hour * hour - parts.minute * minute
3030
- : (later - parts.hour) * hour - parts.minute * minute;
3031
- continue;
3032
- }
3033
- if (!schedule.minutes.includes(parts.minute)) {
3034
- const later = schedule.minutes.find((value) => value > parts.minute);
3035
- candidate += later === undefined ? (60 - parts.minute) * minute : (later - parts.minute) * minute;
3036
- continue;
3037
- }
3038
- return candidate;
3039
- }
3040
- return undefined;
3041
- }
3042
-
3043
- /** Computes the next fire time of a cron rule strictly after the given time. */
3044
- export function schedulecron(rule: { cron: string; timezone?: string }, from: number): number | undefined {
3045
- return cronnext(rule.cron, from, rule.timezone);
3046
- }
3047
-
3048
- /** Computes the next fire time of an interval rule: the reviewed period after the last fire (or the arm time of the first pass) spread with the seeded jitter window so repeated fires never bunch at the minimum. */
3049
- export function scheduleinterval(
3050
- rule: { period: number; jitter?: number },
3051
- lastfire: number | undefined,
3052
- armedat: number,
3053
- seed: number,
3054
- ): number {
3055
- const base = (lastfire ?? armedat) + rule.period;
3056
- const jitter = rule.jitter ?? 0;
3057
- if (jitter <= 0) return base;
3058
- return Math.max(0, Math.round(base - jitter / 2 + seededrandom(seed) * jitter));
3059
- }
3060
-
3061
- /** Returns every scheduled rule whose next fire time has passed; the alarm wake and the opportunistic service worker wakes drain this list. */
3062
- export function listdue(rules: triggerule[], now: number): Array<{ rule: triggerule; overdueby: number }> {
3063
- return rules.flatMap((rule) => {
3064
- if (rule.state.nextfireat === undefined || rule.state.nextfireat > now) return [];
3065
- if (!rule.state.enabled || rule.state.pausedat !== undefined) return [];
3066
- return [{ rule, overdueby: now - rule.state.nextfireat }];
3067
- });
3068
- }
3069
-
3070
- /** Applies the cooldown window of one rule: a fire inside the window after the last fire is suppressed with the remaining window, a fire at or after it passes. */
3071
- export function applycooldown(rule: triggerule, now: number): { suppressed: boolean; remaining: number } {
3072
- const lastfireat = rule.state.lastfireat;
3073
- if (lastfireat === undefined || rule.state.cooldown <= 0) return { suppressed: false, remaining: 0 };
3074
- const remaining = lastfireat + rule.state.cooldown - now;
3075
- return { suppressed: remaining > 0, remaining: Math.max(0, remaining) };
3076
- }
3077
-
3078
- /** The outcome of one trigger evaluation: the launch plan when the rule fires or the honest suppression reason. */
3079
- export interface triggerdecision {
3080
- fired: boolean;
3081
- suppressed?: string;
3082
- remaining?: number;
3083
- fire?: triggerfire;
3084
- }
3085
-
3086
- /** Evaluates one armed rule against one observation: disabled, paused and rules whose workflow lost its approved review never fire, a run of the same workflow already active dedupes, the cooldown window suppresses and every passing fire carries the triggering url, title and payload into the run context. */
3087
- export function evaluatetrigger(input: {
3088
- rule: triggerule;
3089
- now: number;
3090
- cause: string;
3091
- url?: string;
3092
- title?: string;
3093
- payload?: Record<string, unknown>;
3094
- runactive: boolean;
3095
- workflowreviewed: boolean;
3096
- }): triggerdecision {
3097
- const rule = input.rule;
3098
- if (!rule.state.enabled) return { fired: false, suppressed: "disabled" };
3099
- if (rule.state.pausedat !== undefined) return { fired: false, suppressed: "paused" };
3100
- if (!input.workflowreviewed) return { fired: false, suppressed: "unreviewed" };
3101
- if (input.runactive) return { fired: false, suppressed: "dedupe" };
3102
- const cooldown = applycooldown(rule, input.now);
3103
- if (cooldown.suppressed) return { fired: false, suppressed: "cooldown", remaining: cooldown.remaining };
3104
- const fire: triggerfire = {
3105
- id: crypto.randomUUID(),
3106
- ruleid: rule.id,
3107
- at: input.now,
3108
- cause: input.cause,
3109
- ...(input.url !== undefined ? { url: input.url } : {}),
3110
- ...(input.title !== undefined ? { title: input.title } : {}),
3111
- ...(input.payload !== undefined ? { payload: input.payload } : {}),
3112
- };
3113
- return { fired: true, fire };
3114
- }
3115
-
3116
- /** Queues one fire that arrived while the target run or tab was busy; the queue keeps one pending fire per rule so repeated observations dedupe instead of piling up. */
3117
- export function queuefire(
3118
- queue: triggerfire[],
3119
- fire: triggerfire,
3120
- ): { queue: triggerfire[]; queued: boolean; deduped: boolean } {
3121
- if (queue.some((pending) => pending.ruleid === fire.ruleid)) return { queue, queued: false, deduped: true };
3122
- return { queue: [...queue, fire], queued: true, deduped: false };
3123
- }
3124
-
3125
- /** Drains the queued fires in arrival order through the injected launch seam; a launch that throws leaves the failed fire and the remaining queue intact so the next wake retries it. */
3126
- export async function drainqueue(
3127
- queue: triggerfire[],
3128
- launch: (fire: triggerfire) => Promise<void>,
3129
- ): Promise<{ launched: number; remaining: triggerfire[] }> {
3130
- let remaining = [...queue];
3131
- let launched = 0;
3132
- while (remaining.length > 0) {
3133
- const fire = remaining[0] as triggerfire;
3134
- try {
3135
- await launch(fire);
3136
- } catch {
3137
- return { launched, remaining };
3138
- }
3139
- remaining = remaining.slice(1);
3140
- launched += 1;
3141
- }
3142
- return { launched, remaining };
3143
- }
3144
-
3145
- /** Plans one run per url of a url list rule: every url becomes its own trigger fire carrying the url into the run context; nothing is capped in code. */
3146
- export function runurllist(rule: triggerule, now: number): triggerfire[] {
3147
- const urls = rule.urls ?? [];
3148
- return urls.map((url) => ({ id: crypto.randomUUID(), ruleid: rule.id, at: now, cause: "urllist", url }));
3149
- }
3150
-
3151
- /** Verifies one webhook delivery: the shared secret must equal the reviewed secret compared character by character without early exit and the payload must satisfy every required schema field of its reviewed kind; only verified payloads persist. */
3152
- export function verifywebhook(input: { rule: triggerule; secret: string; payload: unknown }): {
3153
- verified: boolean;
3154
- reason?: string;
3155
- } {
3156
- if (typeof input.rule.secret !== "string" || !input.rule.secret)
3157
- return { verified: false, reason: "The webhook rule carries no reviewed secret." };
3158
- if (!secrectsmatch(input.secret, input.rule.secret))
3159
- return { verified: false, reason: "The webhook secret does not match the reviewed secret of the rule." };
3160
- const payload = input.payload;
3161
- if (!payload || typeof payload !== "object" || Array.isArray(payload))
3162
- return { verified: false, reason: "The webhook payload must be a JSON object." };
3163
- const candidate = payload as Record<string, unknown>;
3164
- for (const field of input.rule.schema ?? []) {
3165
- const value = candidate[field.name];
3166
- if (value === undefined) {
3167
- if (field.required === true)
3168
- return { verified: false, reason: `The required webhook field ${field.name} is missing.` };
3169
- continue;
3170
- }
3171
- if (typeof value !== field.kind)
3172
- return { verified: false, reason: `The webhook field ${field.name} is not a ${field.kind}.` };
3173
- }
3174
- return { verified: true };
3175
- }
3176
-
3177
- /** Compares two secrets character by character without early exit so the comparison time leaks no prefix. */
3178
- function secrectsmatch(left: string, right: string): boolean {
3179
- if (left.length !== right.length) return false;
3180
- let same = true;
3181
- for (let index = 0; index < left.length; index += 1)
3182
- if (left.charCodeAt(index) !== right.charCodeAt(index)) same = false;
3183
- return same;
3184
- }
3185
-
3186
- /** True when one observed page event name matches an event rule subscription of the observed event catalog. */
3187
- export function eventrulematches(rule: triggerule, event: string): boolean {
3188
- return (rule.events ?? []).includes(event);
3189
- }
3190
-
3191
- /** Subscribes the armed event rules to one observed page event: returns every enabled, unpaused event rule whose subscription list names the event of the observed catalog, so the page observation seam (watched mutations, focus shifts, banners, console output, captured errors and navigations) can fire exactly those rules. */
3192
- export function observeevents(rules: triggerule[], event: string): triggerule[] {
3193
- if (!triggereventcatalog.includes(event)) return [];
3194
- return rules.filter(
3195
- (rule) =>
3196
- rule.kind === "event" && rule.state.enabled && rule.state.pausedat === undefined && eventrulematches(rule, event),
3197
- );
3198
- }
3199
-
3200
- /** Suspends every enabled rule by setting its pause marker so session pauses hold fires instead of losing them. */
3201
- export function pauseall(rules: triggerule[], now: number): triggerule[] {
3202
- return rules.map((rule) =>
3203
- rule.state.enabled && rule.state.pausedat === undefined ? updaterule(rule, { state: { pausedat: now } }) : rule,
3204
- );
3205
- }
3206
-
3207
- /** Clears the pause marker of every rule so the resume drains the queued fires through the same gates. */
3208
- export function resumeall(rules: triggerule[]): triggerule[] {
3209
- return rules.map((rule) => {
3210
- if (rule.state.pausedat === undefined) return rule;
3211
- const state = { ...rule.state };
3212
- delete state.pausedat;
3213
- return { ...rule, state };
3214
- });
3215
- }
3216
-
3217
- /** Builds the manual run step preview of one composed workflow: every expanded step with its kind, label, block and control summary so a human always sees what a run will do before confirming it. */
3218
- export function manualpreview(record: workflowrecord, now: number): manualrun {
3219
- return {
3220
- id: crypto.randomUUID(),
3221
- workflowid: record.id,
3222
- preview: record.steps.map((step) => ({
3223
- stepid: step.id,
3224
- kind: step.kind,
3225
- label: step.label,
3226
- ...(step.block !== undefined ? { block: step.block } : {}),
3227
- ...(controlsummary(step) !== undefined
3228
- ? { control: controlsummary(step) as unknown as Record<string, unknown> }
3229
- : {}),
3230
- })),
3231
- at: now,
3232
- };
3233
- }
3234
-
3235
- /** Records the confirmation outcome of one manual run preview; an undecided preview keeps its confirmation open. */
3236
- export function confirmmanualrun(preview: manualrun, confirmed: boolean, now: number): manualrun {
3237
- return { ...preview, confirmed, decidedat: now };
3238
- }
3239
-
3240
- /** Builds the review summary of one armed rule for the trigger list: the family, the match fields, the workflow reference, the effective cooldown and the schedule. */
3241
- export function triggersummary(rule: triggerule): {
3242
- kind: triggerfamily;
3243
- workflowid: string;
3244
- label: string;
3245
- origins?: string[];
3246
- pattern?: string;
3247
- title?: string;
3248
- command?: string;
3249
- key?: string;
3250
- cron?: string;
3251
- timezone?: string;
3252
- period?: number;
3253
- jitter?: number;
3254
- urls?: string[];
3255
- events?: string[];
3256
- fields?: number;
3257
- cooldown: number;
3258
- enabled: boolean;
3259
- nextfireat?: number;
3260
- } {
3261
- return {
3262
- kind: rule.kind,
3263
- workflowid: rule.workflowid,
3264
- label: rule.label,
3265
- ...(rule.origins !== undefined ? { origins: rule.origins } : {}),
3266
- ...(rule.pattern !== undefined ? { pattern: rule.pattern } : {}),
3267
- ...(rule.title !== undefined ? { title: rule.title } : {}),
3268
- ...(rule.command !== undefined ? { command: rule.command } : {}),
3269
- ...(rule.key !== undefined ? { key: rule.key } : {}),
3270
- ...(rule.cron !== undefined
3271
- ? { cron: rule.cron, ...(rule.timezone !== undefined ? { timezone: rule.timezone } : {}) }
3272
- : {}),
3273
- ...(rule.period !== undefined
3274
- ? { period: rule.period, ...(rule.jitter !== undefined ? { jitter: rule.jitter } : {}) }
3275
- : {}),
3276
- ...(rule.urls !== undefined ? { urls: rule.urls } : {}),
3277
- ...(rule.events !== undefined ? { events: rule.events } : {}),
3278
- ...(rule.schema !== undefined ? { fields: rule.schema.length } : {}),
3279
- cooldown: rule.state.cooldown,
3280
- enabled: rule.state.enabled,
3281
- ...(rule.state.nextfireat !== undefined ? { nextfireat: rule.state.nextfireat } : {}),
3282
- };
3283
- }
3284
-
3285
- /** Returns every origin a rule touches so the arm review and the fire gates can check them against the workflow grants: visit origins, the url pattern origin, the url list origins and the webhook delivery origin stay inside the workflow grant list. */
3286
- export function ruleorigins(rule: triggerule): string[] {
3287
- const origins = new Set<string>();
3288
- for (const origin of rule.origins ?? []) origins.add(origin);
3289
- if (rule.pattern !== undefined) {
3290
- const origin = httpsorigin(rule.pattern);
3291
- if (origin !== undefined) origins.add(origin);
3292
- }
3293
- for (const url of rule.urls ?? []) {
3294
- const origin = httpsorigin(url);
3295
- if (origin !== undefined) origins.add(origin);
3296
- }
3297
- return [...origins];
3298
- }
3299
-
3300
- /** True when every origin a rule touches stays inside the granted origin list of its workflow. */
3301
- export function ruleoriginsgranted(rule: triggerule, workfloworigins: string[]): boolean {
3302
- const granted = new Set(workfloworigins);
3303
- return ruleorigins(rule).every((origin) => granted.has(origin));
3304
- }
3305
-
3306
- /* ── Merged from workfloweditor.ts ── */
3307
- import { workflowfileversion } from "./protocol.js";
3308
-
3309
- /**
3310
- * Workflow editor of the 1.1.53 family.
3311
- * Every pure rule of the visual builder lives in this file: the canvas model with nodes, typed binding edges and layout state, the load and save round trips against the composed workflow grammar, the drag and drop snapping to block boundaries, the reorder persistence, the grouping of a selection into a new block, the template insertion with nested parameters, the mini map projection and viewport math, the zoom that keeps step labels readable, the step search, the breakpoint markers with the debug run segmentation, the version diffing, the json and yaml file format for import, export and template sharing, the per site override application and the undo and redo stacks.
3312
- * The module stays pure: the sidepanel renders the model and the background validates saves through the same composeworkflow grammar every other path uses, so no editor artifact bypasses review.
3313
- */
3314
-
3315
- /** The five categories of the block palette: actions, control flow, waits, variables and triggers. */
3316
- export const palettecategories: palettecategory[] = ["actions", "controlflow", "waits", "variables", "triggers"];
3317
-
3318
- /** The curated drop blocks of the palette: one descriptor per canonical block of every category. */
3319
- export const palettenodes: palettenode[] = [
3320
- {
3321
- kind: "click",
3322
- label: "Click an element",
3323
- category: "actions",
3324
- description: "Clicks the reviewed selector target.",
3325
- },
3326
- {
3327
- kind: "type",
3328
- label: "Type text",
3329
- category: "actions",
3330
- description: "Types the reviewed text into the target field.",
3331
- },
3332
- { kind: "navigate", label: "Navigate", category: "actions", description: "Navigates the tab to the reviewed url." },
3333
- { kind: "readtext", label: "Read text", category: "actions", description: "Reads the text of the target element." },
3334
- {
3335
- kind: "scrapetable",
3336
- label: "Scrape a table",
3337
- category: "actions",
3338
- description: "Extracts the reviewed table into a dataset.",
3339
- },
3340
- {
3341
- kind: "fillform",
3342
- label: "Fill a form",
3343
- category: "actions",
3344
- description: "Fills the reviewed form fields from a saved profile.",
3345
- },
3346
- {
3347
- kind: "querytabs",
3348
- label: "Query tabs",
3349
- category: "actions",
3350
- description: "Lists the tabs matching the reviewed query.",
3351
- },
3352
- {
3353
- kind: "fetchurl",
3354
- label: "Fetch a url",
3355
- category: "actions",
3356
- description: "Fetches the reviewed endpoint behind the call consent.",
3357
- },
3358
- {
3359
- kind: "condition",
3360
- label: "Condition",
3361
- category: "controlflow",
3362
- description: "Evaluates one reviewed boolean expression with no page side effect.",
3363
- },
3364
- {
3365
- kind: "branch",
3366
- label: "Branch",
3367
- category: "controlflow",
3368
- description: "Chooses one reviewed path by page state with a mandatory else path.",
3369
- },
3370
- {
3371
- kind: "loop",
3372
- label: "Loop a list",
3373
- category: "controlflow",
3374
- description: "Iterates a list variable binding the item and index per pass.",
3375
- },
3376
- {
3377
- kind: "repeatuntil",
3378
- label: "Repeat until",
3379
- category: "controlflow",
3380
- description: "Reruns the body until the convergence expression holds.",
3381
- },
3382
- {
3383
- kind: "whileloop",
3384
- label: "While loop",
3385
- category: "controlflow",
3386
- description: "Loops while the condition holds inside the reviewed bound.",
3387
- },
3388
- {
3389
- kind: "foreach",
3390
- label: "For each element",
3391
- category: "controlflow",
3392
- description: "Iterates the elements of the reviewed selector.",
3393
- },
3394
- {
3395
- kind: "parallel",
3396
- label: "Parallel branches",
3397
- category: "controlflow",
3398
- description: "Runs branches concurrently and joins them under the reviewed strategy.",
3399
- },
3400
- {
3401
- kind: "trycatch",
3402
- label: "Try catch",
3403
- category: "controlflow",
3404
- description: "Wraps fragile steps with a catch handler, retries and timeouts.",
3405
- },
3406
- {
3407
- kind: "delay",
3408
- label: "Delay",
3409
- category: "waits",
3410
- description: "Sleeps the reviewed base inside the jitter window.",
3411
- },
3412
- {
3413
- kind: "waitelement",
3414
- label: "Wait for element",
3415
- category: "waits",
3416
- description: "Polls the reviewed selector until appearance or timeout.",
3417
- },
3418
- { kind: "wait", label: "Wait", category: "waits", description: "Waits the reviewed duration." },
3419
- {
3420
- kind: "waitfor",
3421
- label: "Wait for target",
3422
- category: "waits",
3423
- description: "Waits until the reviewed target exists.",
3424
- },
3425
- {
3426
- kind: "waittext",
3427
- label: "Wait for text",
3428
- category: "waits",
3429
- description: "Waits until the reviewed text appears.",
3430
- },
3431
- {
3432
- kind: "waitquiet",
3433
- label: "Wait for quiet",
3434
- category: "waits",
3435
- description: "Waits until the page stops mutating.",
3436
- },
3437
- { kind: "waitload", label: "Wait for load", category: "waits", description: "Waits until the navigation settles." },
3438
- {
3439
- kind: "compute",
3440
- label: "Compute",
3441
- category: "variables",
3442
- description: "Evaluates one reviewed expression into the result variable.",
3443
- },
3444
- {
3445
- kind: "extractvars",
3446
- label: "Extract variables",
3447
- category: "variables",
3448
- description: "Applies the reviewed regex and stores the named captures.",
3449
- },
3450
- {
3451
- kind: "savetemplate",
3452
- label: "Save template",
3453
- category: "variables",
3454
- description: "Shares the reviewed step as a reusable template.",
3455
- },
3456
- {
3457
- kind: "visitrule",
3458
- label: "Visit rule",
3459
- category: "triggers",
3460
- description: "Fires on navigations to the reviewed origins.",
3461
- },
3462
- {
3463
- kind: "urlrule",
3464
- label: "Url rule",
3465
- category: "triggers",
3466
- description: "Fires when the url matches the reviewed glob pattern.",
3467
- },
3468
- {
3469
- kind: "cronrule",
3470
- label: "Cron rule",
3471
- category: "triggers",
3472
- description: "Fires on the reviewed five field cron schedule.",
3473
- },
3474
- {
3475
- kind: "intervalrule",
3476
- label: "Interval rule",
3477
- category: "triggers",
3478
- description: "Fires every reviewed period with the jitter spread.",
3479
- },
3480
- {
3481
- kind: "webhookrule",
3482
- label: "Webhook rule",
3483
- category: "triggers",
3484
- description: "Fires on a secret verified webhook delivery.",
3485
- },
3486
- {
3487
- kind: "eventrule",
3488
- label: "Event rule",
3489
- category: "triggers",
3490
- description: "Fires on the observed page events of the catalog.",
3491
- },
3492
- ];
3493
-
3494
- /** The reviewed option schemas the step library documents per kind; kinds without an entry document no reviewed options of their own. */
3495
- const optionschemas: Record<
3496
- string,
3497
- Array<{ name: string; kind: "string" | "number" | "boolean"; required?: boolean }>
3498
- > = {
3499
- delay: [
3500
- { name: "base", kind: "number", required: true },
3501
- { name: "jitter", kind: "number" },
3502
- ],
3503
- waitelement: [
3504
- { name: "timeout", kind: "number" },
3505
- { name: "poll", kind: "number" },
3506
- ],
3507
- compute: [{ name: "expression", kind: "string", required: true }],
3508
- extractvars: [{ name: "rule", kind: "string", required: true }],
3509
- composeworkflow: [
3510
- { name: "name", kind: "string", required: true },
3511
- { name: "version", kind: "number" },
3512
- ],
3513
- runworkflow: [
3514
- { name: "workflowid", kind: "string", required: true },
3515
- { name: "reviewed", kind: "boolean", required: true },
3516
- { name: "variables", kind: "string" },
3517
- { name: "background", kind: "boolean" },
3518
- ],
3519
- dryrun: [{ name: "workflowid", kind: "string", required: true }],
3520
- loop: [{ name: "loop", kind: "string", required: true }],
3521
- repeatuntil: [{ name: "repeatuntil", kind: "string", required: true }],
3522
- whileloop: [{ name: "whileloop", kind: "string", required: true }],
3523
- foreach: [{ name: "foreach", kind: "string", required: true }],
3524
- parallel: [{ name: "parallel", kind: "string", required: true }],
3525
- trycatch: [{ name: "trycatch", kind: "string", required: true }],
3526
- };
3527
-
3528
- /** Classifies one action kind into its palette category: the ten trigger kinds, the eight control flow kinds, the wait family, the variable family and everything else an action. */
3529
- function stepcategory(kind: string): palettecategory {
3530
- if (triggerkinds.includes(kind)) return "triggers";
3531
- if (controlflowkinds.includes(kind)) return "controlflow";
3532
- if (kind.startsWith("wait") || kind === "spawait" || kind === "delay") return "waits";
3533
- if (kind === "compute" || kind === "extractvars" || kind === "savetemplate") return "variables";
3534
- return "actions";
3535
- }
3536
-
3537
- /** Builds the step library over every reviewed action kind the policy table knows, grouped by category with the documented option schema of the kinds that carry one. */
3538
- export function buildsteplibrary(kinds: string[]): steplibraryentry[] {
3539
- return [...new Set(kinds)]
3540
- .sort()
3541
- .map((kind) => ({ kind, category: stepcategory(kind), optionschema: optionschemas[kind] ?? [] }));
3542
- }
3543
-
3544
- /** The row height every canvas node occupies; the layout stacks steps top to bottom and block columns side by side. */
3545
- const noderowheight = 96;
3546
-
3547
- /** The column width of one block container on the canvas. */
3548
- const blockcolumnwidth = 280;
3549
-
3550
- /** The x origin of the main column of the canvas. */
3551
- const canvasoriginx = 40;
3552
-
3553
- /** Strips the undo and redo stacks of one model so a snapshot never carries nested history. */
3554
- function snapshotof(model: editormodel): editormodel {
3555
- const { undo, redo, dirty, ...rest } = model;
3556
- void undo;
3557
- void redo;
3558
- void dirty;
3559
- return { ...rest, dirty: true };
3560
- }
3561
-
3562
- /** Pushes one edit onto the undo stack and clears the redo stack; every canvas edit routes through here. */
3563
- function withundo(model: editormodel, next: editormodel): editormodel {
3564
- const undo = [...(model.undo ?? []), snapshotof(model)];
3565
- const { redo, ...rest } = next;
3566
- void redo;
3567
- return { ...rest, dirty: true, undo };
3568
- }
3569
-
3570
- /** Returns the id of one canvas node: the explicit node id, the step id or the invoked block name. */
3571
- function nodeidof(node: editornode): string {
3572
- return (
3573
- node.id ?? (node.step !== undefined ? node.step.id : node.invocation !== undefined ? node.invocation.block : "")
3574
- );
3575
- }
3576
-
3577
- /** Computes the layout width and height the nodes of one model occupy. */
3578
- function layoutsizeof(nodes: editornode[]): { width: number; height: number } {
3579
- const width = Math.max(640, ...nodes.map((node) => node.x + blockcolumnwidth)) + 40;
3580
- const height = Math.max(480, ...nodes.map((node) => node.y + noderowheight)) + 40;
3581
- return { width, height };
3582
- }
3583
-
3584
- /** Converts one composed workflow record into the canvas model: one node per top level step, one invocation node per contiguous block region of the expanded step list with a unique id even when one block is invoked many times, the bindings of every step lifted into typed edges and the layout stacked top to bottom with the block columns side by side. */
3585
- export function loadworkflow(record: workflowrecord, layout?: editorlayout): editormodel {
3586
- const blocks = record.blocks.map((block) => ({ ...block, steps: block.steps.map((entry) => ({ ...entry })) }));
3587
- const blockcolumn = (blockname: string): number => {
3588
- const index = blocks.findIndex((block) => block.name === blockname);
3589
- return index < 0 ? canvasoriginx : canvasoriginx + (index + 1) * blockcolumnwidth;
3590
- };
3591
- const invocationcount = new Map<string, number>();
3592
- const nodes: editornode[] = [];
3593
- const edges: editoredge[] = [];
3594
- let index = 0;
3595
- while (index < record.steps.length) {
3596
- const step = record.steps[index] as workflowstep;
3597
- for (const binding of step.bindings ?? [])
3598
- edges.push({
3599
- from: binding.stepid,
3600
- to: step.id,
3601
- variable: binding.variable,
3602
- kind: binding.kind,
3603
- ...(binding.path !== undefined ? { path: binding.path } : {}),
3604
- });
3605
- if (step.block === undefined) {
3606
- const { bindings, block, params, ...rest } = step;
3607
- void bindings;
3608
- void block;
3609
- void params;
3610
- nodes.push({ step: { ...rest }, x: canvasoriginx, y: 60 + nodes.length * noderowheight });
3611
- index += 1;
3612
- continue;
3613
- }
3614
- const blockname = step.block;
3615
- let end = index;
3616
- while (end < record.steps.length && (record.steps[end] as workflowstep).block === blockname) end += 1;
3617
- const region = record.steps.slice(index, end) as workflowstep[];
3618
- const count = (invocationcount.get(blockname) ?? 0) + 1;
3619
- invocationcount.set(blockname, count);
3620
- const params = region.flatMap((entry) => entry.params ?? []);
3621
- nodes.push({
3622
- id: count === 1 ? blockname : `${blockname}${count}`,
3623
- invocation: {
3624
- block: blockname,
3625
- label: blockname,
3626
- ...(params.length > 0 ? { params: params.map((param) => ({ ...param })) } : {}),
3627
- },
3628
- x: blockcolumn(blockname),
3629
- y: 60 + nodes.length * noderowheight,
3630
- });
3631
- index = end;
3632
- }
3633
- const size = layouttypeof(nodes, layout);
3634
- const model: editormodel = {
3635
- workflowid: record.id,
3636
- name: record.name,
3637
- version: record.version,
3638
- origins: [...record.origins],
3639
- nodes,
3640
- edges,
3641
- blocks,
3642
- layout: size,
3643
- minimap: emptyminimap(),
3644
- dirty: false,
3645
- };
3646
- return { ...model, minimap: renderminimap(model).minimap };
3647
- }
3648
-
3649
- /** Merges one explicit layout with the computed node bounds so a reopened canvas keeps its size while new nodes stay visible. */
3650
- function layouttypeof(nodes: editornode[], layout?: editorlayout): editorlayout {
3651
- const size = layoutsizeof(nodes);
3652
- if (!layout) return { width: size.width, height: size.height, viewportx: 0, viewporty: 0, zoom: 1 };
3653
- return {
3654
- width: Math.max(size.width, layout.width),
3655
- height: Math.max(size.height, layout.height),
3656
- viewportx: layout.viewportx,
3657
- viewporty: layout.viewporty,
3658
- zoom: layout.zoom,
3659
- };
3660
- }
3661
-
3662
- /** Builds the empty mini map of a model before the first projection. */
3663
- function emptyminimap(): minimapstate {
3664
- return { width: 160, height: 100, scale: 0, zoom: 1, viewport: { x: 0, y: 0, width: 0, height: 0 } };
3665
- }
3666
-
3667
- /** Validates the canvas model and converts it back into one composed workflow record: every node is a step or a block invocation, every edge links the output of an earlier node into a later node so no cycle forms, block child bindings stay inside their block and the composed record passes the full workflow grammar. */
3668
- export function saveworkflow(
3669
- model: editormodel,
3670
- input: { now: number; kindallowed?: (kind: string) => boolean; riskof?: (kind: string) => actionrisk },
3671
- ): workflowrecord {
3672
- if (typeof model.name !== "string" || !model.name.trim())
3673
- throw new Error("The workflow name must be a non-empty string.");
3674
- if (typeof model.version !== "number" || !Number.isInteger(model.version) || model.version < 1)
3675
- throw new Error("The workflow version must be a positive integer.");
3676
- if (!Array.isArray(model.origins) || model.origins.length === 0)
3677
- throw new Error("A workflow needs at least one granted HTTPS origin.");
3678
- const ids = new Set<string>();
3679
- for (const node of model.nodes) {
3680
- if ((node.step === undefined) === (node.invocation === undefined))
3681
- throw new Error("Every canvas node must be exactly one workflow step or one block invocation.");
3682
- const id = nodeidof(node);
3683
- if (!id || ids.has(id)) throw new Error(`The canvas node id ${id || "(empty)"} must be unique.`);
3684
- ids.add(id);
3685
- }
3686
- /** Walks the top level entries in execution order and maps every reachable step id onto its position so the edge check answers cycles. */
3687
- const positionof = new Map<string, number>();
3688
- let position = 0;
3689
- for (const node of model.nodes) {
3690
- if (node.step !== undefined) {
3691
- positionof.set(node.step.id, position);
3692
- position += 1;
3693
- continue;
3694
- }
3695
- const block = model.blocks.find((entry) => entry.name === node.invocation?.block);
3696
- if (!block) throw new Error(`The block ${node.invocation?.block ?? ""} of the canvas has no definition.`);
3697
- const walk = (entries: Array<workflowstep | blockinvocation>): void => {
3698
- for (const entry of entries) {
3699
- if ("kind" in entry && "label" in entry && !("block" in entry)) {
3700
- positionof.set(entry.id, position);
3701
- position += 1;
3702
- continue;
3703
- }
3704
- const nested = model.blocks.find((candidate) => candidate.name === (entry as blockinvocation).block);
3705
- if (!nested) throw new Error(`The block ${(entry as blockinvocation).block} of the canvas has no definition.`);
3706
- walk(nested.steps);
3707
- }
3708
- };
3709
- walk(block.steps);
3710
- }
3711
- for (const edge of model.edges) {
3712
- if (!positionof.has(edge.from))
3713
- throw new Error(`The edge of ${edge.variable} references the unknown source step ${edge.from}.`);
3714
- if (!positionof.has(edge.to))
3715
- throw new Error(`The edge of ${edge.variable} references the unknown target step ${edge.to}.`);
3716
- if ((positionof.get(edge.from) as number) >= (positionof.get(edge.to) as number))
3717
- throw new Error(
3718
- `The edge of ${edge.variable} runs backwards from ${edge.from} into ${edge.to} and would form a cycle.`,
3719
- );
3720
- }
3721
- /** Collects the bindings one step id receives from the canvas edges. */
3722
- const bindingsof = (stepid: string): variablebinding[] =>
3723
- model.edges
3724
- .filter((edge) => edge.to === stepid)
3725
- .map((edge) => ({
3726
- variable: edge.variable,
3727
- kind: edge.kind,
3728
- stepid: edge.from,
3729
- ...(edge.path !== undefined ? { path: edge.path } : {}),
3730
- }));
3731
- const entries: Array<workflowstep | blockinvocation> = [];
3732
- const attached = new Map<string, workflowstep[]>();
3733
- for (const node of model.nodes) {
3734
- if (node.invocation !== undefined) {
3735
- entries.push({ ...node.invocation });
3736
- continue;
3737
- }
3738
- const step = node.step as workflowstep;
3739
- const bindings = bindingsof(step.id);
3740
- const { block, params, ...rest } = { ...step, ...(bindings.length > 0 ? { bindings } : {}) };
3741
- void params;
3742
- const carried: workflowstep = rest;
3743
- if (block !== undefined) {
3744
- if (!model.blocks.some((candidate) => candidate.name === block))
3745
- throw new Error(`The step ${step.id} attaches to the unknown block ${block}.`);
3746
- const list = attached.get(block) ?? [];
3747
- list.push(carried);
3748
- attached.set(block, list);
3749
- continue;
3750
- }
3751
- entries.push(carried);
3752
- }
3753
- const blocks = model.blocks.map((block) => {
3754
- const snapped = attached.get(block.name) ?? [];
3755
- const snappedids = new Set(snapped.map((step) => step.id));
3756
- const carried: Array<workflowstep | blockinvocation> = [];
3757
- for (const entry of block.steps) {
3758
- if ("kind" in entry && "label" in entry && !("block" in entry) && snappedids.has((entry as workflowstep).id))
3759
- continue;
3760
- carried.push(entry);
3761
- }
3762
- const steps: Array<workflowstep | blockinvocation> = [...carried, ...snapped];
3763
- const withbindings: Array<workflowstep | blockinvocation> = [];
3764
- for (const entry of steps) {
3765
- if (!("kind" in entry && "label" in entry && !("block" in entry))) {
3766
- withbindings.push(entry);
3767
- continue;
3768
- }
3769
- const bindings = bindingsof((entry as workflowstep).id);
3770
- const {
3771
- block: inner,
3772
- params,
3773
- ...rest
3774
- } = { ...(entry as workflowstep), ...(bindings.length > 0 ? { bindings } : {}) };
3775
- void inner;
3776
- void params;
3777
- withbindings.push(rest as workflowstep);
3778
- }
3779
- return { ...block, steps: withbindings };
3780
- });
3781
- const composed = composeworkflow({
3782
- id: model.workflowid,
3783
- name: model.name,
3784
- version: model.version,
3785
- origins: [...model.origins],
3786
- steps: entries,
3787
- blocks: blocks.map((block) => ({ ...block })),
3788
- now: input.now,
3789
- ...(input.kindallowed !== undefined ? { kindallowed: input.kindallowed } : {}),
3790
- ...(input.riskof !== undefined ? { riskof: input.riskof } : {}),
3791
- });
3792
- const checked = validateworkflow(composed, input.kindallowed !== undefined ? { kindallowed: input.kindallowed } : {});
3793
- if (!checked.allowed) throw new Error(checked.reason ?? "The canvas model failed the workflow grammar.");
3794
- return composed;
3795
- }
3796
-
3797
- /** Attaches one step to a block boundary: the dragged position snaps onto the reviewed grid and the nearest block column attaches the step into that block while the main column detaches it. */
3798
- export function snapnode(model: editormodel, nodeid: string, x: number, y: number, grid = 20): editormodel {
3799
- if (!Number.isFinite(grid) || grid <= 0) throw new Error("The snap grid must be a positive number.");
3800
- const index = model.nodes.findIndex((node) => nodeidof(node) === nodeid);
3801
- if (index < 0) throw new Error(`No canvas node matches ${nodeid}.`);
3802
- const node = model.nodes[index] as editornode;
3803
- if (node.step === undefined)
3804
- throw new Error("A block invocation node attaches through its own definition, not through snapping.");
3805
- const snappedx = Math.round(x / grid) * grid;
3806
- const snappedy = Math.round(y / grid) * grid;
3807
- let attached: string | undefined;
3808
- for (const [blockindex, block] of model.blocks.entries()) {
3809
- const columnx = canvasoriginx + (blockindex + 1) * blockcolumnwidth;
3810
- if (Math.abs(snappedx - columnx) <= blockcolumnwidth / 2) attached = block.name;
3811
- }
3812
- const { block: priorblock, ...rest } = node.step;
3813
- void priorblock;
3814
- const step: workflowstep = { ...rest, ...(attached !== undefined ? { block: attached } : {}) };
3815
- const nodes = model.nodes.map((candidate, position) =>
3816
- position === index ? { step, x: snappedx, y: snappedy } : candidate,
3817
- );
3818
- const size = layouttypeof(nodes, model.layout);
3819
- const next: editormodel = { ...model, nodes, layout: size };
3820
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3821
- }
3822
-
3823
- /** Persists one drag and drop ordering: the node moves to the reviewed index of the top level list while the edges stay attached to their step ids. */
3824
- export function reordersteps(model: editormodel, nodeid: string, index: number): editormodel {
3825
- const current = model.nodes.findIndex((node) => nodeidof(node) === nodeid);
3826
- if (current < 0) throw new Error(`No canvas node matches ${nodeid}.`);
3827
- if (!Number.isInteger(index) || index < 0 || index > model.nodes.length - 1)
3828
- throw new Error("The reorder index must address an existing position of the canvas list.");
3829
- const nodes = [...model.nodes];
3830
- const [moved] = nodes.splice(current, 1);
3831
- if (!moved) throw new Error("The reordered canvas node vanished.");
3832
- nodes.splice(index, 0, moved);
3833
- const next: editormodel = { ...model, nodes };
3834
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3835
- }
3836
-
3837
- /** Moves many selected steps into a new block: the definition collects the selected steps in their current order and one invocation node replaces the first selected position. */
3838
- export function groupselect(model: editormodel, nodeids: string[], blockname: string): editormodel {
3839
- if (!/^[a-z][a-z0-9]*$/.test(blockname)) throw new Error("The block name must be a unique lowercase word.");
3840
- if (model.blocks.some((block) => block.name === blockname))
3841
- throw new Error(`The block name ${blockname} already exists on the canvas.`);
3842
- const selected = nodeids.map((id) => {
3843
- const node = model.nodes.find((candidate) => nodeidof(candidate) === id);
3844
- if (!node || node.step === undefined)
3845
- throw new Error(`The grouping selection must address step nodes; ${id} is not one.`);
3846
- return node;
3847
- });
3848
- if (selected.length === 0) throw new Error("The grouping selection needs at least one step node.");
3849
- const steps = selected.map((node) => node.step as workflowstep);
3850
- const blocks = [...model.blocks, { name: blockname, label: blockname, steps: steps.map((step) => ({ ...step })) }];
3851
- const firstindex = model.nodes.findIndex((node) => nodeidof(node) === (nodeids[0] as string));
3852
- const invocationnode: editornode = {
3853
- id: blockname,
3854
- invocation: { block: blockname, label: blockname },
3855
- x: (selected[0] as editornode).x,
3856
- y: (selected[0] as editornode).y,
3857
- };
3858
- const nodes: editornode[] = [];
3859
- model.nodes.forEach((node, index) => {
3860
- if (nodeids.includes(nodeidof(node))) {
3861
- if (index === firstindex) nodes.push(invocationnode);
3862
- return;
3863
- }
3864
- nodes.push(node);
3865
- });
3866
- const next: editormodel = { ...model, nodes, blocks };
3867
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3868
- }
3869
-
3870
- /** Inserts one shared step template with its nested parameters: the template step becomes a canvas node at the reviewed index and the parameters ride with the step into its block scope. */
3871
- export function expandtemplate(
3872
- model: editormodel,
3873
- template: steptemplate,
3874
- params: nestedparam[] = [],
3875
- index?: number,
3876
- ): editormodel {
3877
- const parsed = steptemplateof(template);
3878
- if (!parsed) throw new Error("The template does not carry one reviewed workflow step.");
3879
- const checkedparams = params.flatMap((param) =>
3880
- nestedparamof(param) !== undefined ? [nestedparamof(param) as nestedparam] : [],
3881
- );
3882
- if (checkedparams.length !== params.length)
3883
- throw new Error(
3884
- "The template expansion carries one malformed nested parameter; every parameter stays a reviewed lowercase word with its variable kind.",
3885
- );
3886
- let id = parsed.step.id;
3887
- let suffix = 2;
3888
- const taken = new Set(model.nodes.map((node) => nodeidof(node)));
3889
- while (taken.has(id)) {
3890
- id = `${parsed.step.id}${suffix}`;
3891
- suffix += 1;
3892
- }
3893
- const step: workflowstep = {
3894
- ...parsed.step,
3895
- id,
3896
- ...(checkedparams.length > 0 ? { params: checkedparams.map((param) => ({ ...param })) } : {}),
3897
- };
3898
- const position =
3899
- index !== undefined && Number.isInteger(index) && index >= 0 && index <= model.nodes.length
3900
- ? index
3901
- : model.nodes.length;
3902
- const nodes = [
3903
- ...model.nodes.slice(0, position),
3904
- { step, x: canvasoriginx, y: 60 + position * noderowheight },
3905
- ...model.nodes.slice(position),
3906
- ];
3907
- const next: editormodel = { ...model, nodes };
3908
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3909
- }
3910
-
3911
- /** Inserts one new step node onto the canvas at the reviewed index: the palette and the step library drop their kinds through here so every insertion rides the undo stack. */
3912
- export function addnode(model: editormodel, step: workflowstep, index?: number): editormodel {
3913
- const normalized = workflowstepof(step);
3914
- if (!normalized) throw new Error("The canvas insertion needs one reviewed workflow step.");
3915
- let id = normalized.id;
3916
- let suffix = 2;
3917
- const taken = new Set(model.nodes.map((node) => nodeidof(node)));
3918
- while (taken.has(id)) {
3919
- id = `${normalized.id}${suffix}`;
3920
- suffix += 1;
3921
- }
3922
- const position =
3923
- index !== undefined && Number.isInteger(index) && index >= 0 && index <= model.nodes.length
3924
- ? index
3925
- : model.nodes.length;
3926
- const nodes = [
3927
- ...model.nodes.slice(0, position),
3928
- { step: { ...normalized, id }, x: canvasoriginx, y: 60 + position * noderowheight },
3929
- ...model.nodes.slice(position),
3930
- ];
3931
- const next: editormodel = { ...model, nodes };
3932
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3933
- }
3934
-
3935
- /** Replaces the payload of one step node of the canvas: the step inspector edits its target, value, options, expression and extract fields through here so every edit rides the undo stack. */
3936
- export function editstep(model: editormodel, step: workflowstep): editormodel {
3937
- const normalized = workflowstepof(step);
3938
- if (!normalized) throw new Error("The step inspector edit needs one reviewed workflow step.");
3939
- const index = model.nodes.findIndex((node) => node.step?.id === normalized.id);
3940
- if (index < 0) throw new Error(`No canvas step matches ${normalized.id}.`);
3941
- const node = model.nodes[index] as editornode;
3942
- const nodes = model.nodes.map((candidate, position) =>
3943
- position === index
3944
- ? {
3945
- step: {
3946
- ...normalized,
3947
- ...(node.step?.block !== undefined ? { block: node.step.block } : {}),
3948
- ...(node.step?.breakpoint === true ? { breakpoint: true } : {}),
3949
- },
3950
- x: node.x,
3951
- y: node.y,
3952
- }
3953
- : candidate,
3954
- );
3955
- const next: editormodel = { ...model, nodes };
3956
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
3957
- }
3958
-
3959
- /** Projects the full canvas into the mini map: the projection scale fits every node into the mini size and the viewport rectangle follows the layout viewport and zoom. */
3960
- export function renderminimap(
3961
- model: editormodel,
3962
- width = 160,
3963
- height = 100,
3964
- ): { minimap: minimapstate; nodes: Array<{ id: string; x: number; y: number }> } {
3965
- if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0)
3966
- throw new Error("The mini map size must be positive.");
3967
- const canvaswidth = Math.max(1, model.layout.width);
3968
- const canvasheight = Math.max(1, model.layout.height);
3969
- const scale = Math.min(width / canvaswidth, height / canvasheight);
3970
- const zoom = model.layout.zoom > 0 ? model.layout.zoom : 1;
3971
- const visiblewidth = canvaswidth / zoom;
3972
- const visibleheight = canvasheight / zoom;
3973
- const viewport = {
3974
- x: Math.max(0, Math.min(model.layout.viewportx, canvaswidth)) * scale,
3975
- y: Math.max(0, Math.min(model.layout.viewporty, canvasheight)) * scale,
3976
- width: visiblewidth * scale,
3977
- height: visibleheight * scale,
3978
- };
3979
- const nodes = model.nodes.map((node) => ({ id: nodeidof(node), x: node.x * scale, y: node.y * scale }));
3980
- return { minimap: { width, height, scale, zoom, viewport }, nodes };
3981
- }
3982
-
3983
- /** Jumps the canvas to a clicked mini map region: the click converts back into canvas coordinates and the viewport centers on it inside the canvas bounds. */
3984
- export function minimapfocus(model: editormodel, x: number, y: number, width = 160, height = 100): editormodel {
3985
- const projection = renderminimap(model, width, height);
3986
- if (projection.minimap.scale <= 0) return model;
3987
- const canvasx = x / projection.minimap.scale;
3988
- const canvasy = y / projection.minimap.scale;
3989
- const zoom = model.layout.zoom > 0 ? model.layout.zoom : 1;
3990
- const visiblewidth = model.layout.width / zoom;
3991
- const visibleheight = model.layout.height / zoom;
3992
- const viewportx = Math.max(0, Math.min(canvasx - visiblewidth / 2, Math.max(0, model.layout.width - visiblewidth)));
3993
- const viewporty = Math.max(
3994
- 0,
3995
- Math.min(canvasy - visibleheight / 2, Math.max(0, model.layout.height - visibleheight)),
3996
- );
3997
- const next: editormodel = { ...model, layout: { ...model.layout, viewportx, viewporty } };
3998
- return { ...next, minimap: renderminimap(next).minimap };
3999
- }
4000
-
4001
- /** Sets the canvas zoom to any positive user value with no ceiling while the step labels compensate so they stay readable at every zoom level: the returned label scale grows the labels relative to the canvas once the zoom shrinks below the readable floor. */
4002
- export function zoomcanvas(model: editormodel, zoom: number): { model: editormodel; labelscale: number } {
4003
- if (!Number.isFinite(zoom) || zoom <= 0)
4004
- throw new Error("The canvas zoom must be a positive number with no code ceiling.");
4005
- const next: editormodel = { ...model, layout: { ...model.layout, zoom } };
4006
- const labelscale = zoom < 1 ? 1 / zoom : 1;
4007
- return { model: { ...next, minimap: renderminimap(next).minimap }, labelscale };
4008
- }
4009
-
4010
- /** Finds steps by label, kind or variable name: the search answers the matching nodes with the reasons they matched, case insensitive. */
4011
- export function searchsteps(
4012
- model: editormodel,
4013
- query: string,
4014
- ): Array<{ id: string; label: string; kind: string; matched: string[] }> {
4015
- const needle = query.trim().toLowerCase();
4016
- if (!needle) return [];
4017
- const results: Array<{ id: string; label: string; kind: string; matched: string[] }> = [];
4018
- for (const node of model.nodes) {
4019
- if (node.step === undefined) continue;
4020
- const matched: string[] = [];
4021
- if (node.step.label.toLowerCase().includes(needle)) matched.push("label");
4022
- if (node.step.kind.toLowerCase().includes(needle)) matched.push("kind");
4023
- const variables = [
4024
- ...model.edges
4025
- .filter((edge) => edge.to === node.step?.id || edge.from === node.step?.id)
4026
- .map((edge) => edge.variable),
4027
- ...(node.step.expression !== undefined ? [node.step.expression.result] : []),
4028
- ...(node.step.extract !== undefined ? node.step.extract.groups : []),
4029
- ];
4030
- if (variables.some((name) => name.toLowerCase().includes(needle))) matched.push("variable");
4031
- if (matched.length > 0) results.push({ id: node.step.id, label: node.step.label, kind: node.step.kind, matched });
4032
- }
4033
- return results;
4034
- }
4035
-
4036
- /** Toggles the breakpoint marker of one step for editor debugging; a debug run pauses right before a marked step, and the marker rides the steps inside block definitions too. */
4037
- export function markbreakpoint(model: editormodel, stepid: string): editormodel {
4038
- const toggle = (step: workflowstep): workflowstep => {
4039
- const { breakpoint, ...rest } = step;
4040
- void breakpoint;
4041
- return breakpoint === true ? rest : { ...rest, breakpoint: true };
4042
- };
4043
- const index = model.nodes.findIndex((node) => node.step?.id === stepid);
4044
- if (index >= 0) {
4045
- const node = model.nodes[index] as editornode;
4046
- const step = node.step as workflowstep;
4047
- const nodes = model.nodes.map((candidate, position) =>
4048
- position === index ? { step: toggle(step), x: candidate.x, y: candidate.y } : candidate,
4049
- );
4050
- const next: editormodel = { ...model, nodes };
4051
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
4052
- }
4053
- const blocks = model.blocks.map((block) => {
4054
- const stepindex = block.steps.findIndex(
4055
- (entry) => "kind" in entry && "label" in entry && !("block" in entry) && (entry as workflowstep).id === stepid,
4056
- );
4057
- if (stepindex < 0) return block;
4058
- const steps = block.steps.map((entry, position) =>
4059
- position === stepindex ? toggle(entry as workflowstep) : entry,
4060
- );
4061
- return { ...block, steps };
4062
- });
4063
- if (blocks.every((block, position) => block === model.blocks[position]))
4064
- throw new Error(`No canvas step matches ${stepid}.`);
4065
- const next: editormodel = { ...model, blocks };
4066
- return withundo(model, next);
4067
- }
4068
-
4069
- /** Plans one debug run segment: the run executes the steps from the cursor up to the step before the next breakpoint, pauses at the breakpoint step id and reports the steps remaining after it; a run without breakpoints runs to the end. */
4070
- export function runtobreakpoint(input: { record: workflowrecord; cursor?: number; breakpoints: string[] }): {
4071
- until: number;
4072
- pausat: string | undefined;
4073
- remaining: number;
4074
- } {
4075
- const cursor = input.cursor !== undefined && Number.isInteger(input.cursor) && input.cursor >= 0 ? input.cursor : 0;
4076
- const marked = new Set(input.breakpoints);
4077
- for (let index = cursor; index < input.record.steps.length; index += 1) {
4078
- const step = input.record.steps[index] as workflowstep;
4079
- if (step.breakpoint === true || marked.has(step.id)) {
4080
- return { until: index, pausat: step.id, remaining: input.record.steps.length - index };
4081
- }
4082
- }
4083
- return { until: input.record.steps.length, pausat: undefined, remaining: 0 };
4084
- }
4085
-
4086
- /** Compares two workflow versions: the steps the newer version added, removed and changed with the field names that changed. */
4087
- export function diffversions(from: workflowrecord, to: workflowrecord, now: number): versiondiff {
4088
- const fromsteps = new Map(from.steps.map((step) => [step.id, step]));
4089
- const tosteps = new Map(to.steps.map((step) => [step.id, step]));
4090
- const added: versiondiff["added"] = [];
4091
- const removed: versiondiff["removed"] = [];
4092
- const changed: versiondiff["changed"] = [];
4093
- for (const step of to.steps) {
4094
- const prior = fromsteps.get(step.id);
4095
- if (!prior) {
4096
- added.push({ stepid: step.id, kind: step.kind, label: step.label });
4097
- continue;
4098
- }
4099
- const changes: string[] = [];
4100
- if (prior.label !== step.label) changes.push("label");
4101
- if (prior.kind !== step.kind) changes.push("kind");
4102
- if (prior.target !== step.target) changes.push("target");
4103
- if (prior.value !== step.value) changes.push("value");
4104
- if (prior.options !== step.options) changes.push("options");
4105
- if (JSON.stringify(prior.expression) !== JSON.stringify(step.expression)) changes.push("expression");
4106
- if (JSON.stringify(prior.extract) !== JSON.stringify(step.extract)) changes.push("extract");
4107
- if (JSON.stringify(prior.bindings) !== JSON.stringify(step.bindings)) changes.push("bindings");
4108
- if (changes.length > 0) changed.push({ stepid: step.id, kind: step.kind, label: step.label, changes });
4109
- }
4110
- for (const step of from.steps) {
4111
- if (!tosteps.has(step.id)) removed.push({ stepid: step.id, kind: step.kind, label: step.label });
4112
- }
4113
- return { workflowid: to.id, from: from.version, to: to.version, added, removed, changed, at: now };
4114
- }
4115
-
4116
- /** Serializes one workflow record with its version metadata into a workflow file of the reviewed json or yaml format. */
4117
- export function exportworkflow(
4118
- record: workflowrecord,
4119
- format: exportformat,
4120
- note?: string,
4121
- now?: number,
4122
- ): { format: exportformat; contents: string; file: workflowfile } {
4123
- const file: workflowfile = {
4124
- format: workflowfileversion,
4125
- exportedat: now ?? Date.now(),
4126
- workflow: record,
4127
- ...(note !== undefined && note.trim() !== "" ? { note } : {}),
4128
- templates: [],
4129
- };
4130
- return { format, contents: serializefile(file, format), file };
4131
- }
4132
-
4133
- /** Packs one workflow with its shared step templates into a single shareable file so a whole library travels together. */
4134
- export function shareworkflow(
4135
- record: workflowrecord,
4136
- templates: steptemplate[],
4137
- format: exportformat,
4138
- note?: string,
4139
- now?: number,
4140
- ): { format: exportformat; contents: string; file: workflowfile } {
4141
- const file: workflowfile = {
4142
- format: workflowfileversion,
4143
- exportedat: now ?? Date.now(),
4144
- workflow: record,
4145
- ...(note !== undefined && note.trim() !== "" ? { note } : {}),
4146
- templates: templates.map((template) => ({ ...template })),
4147
- };
4148
- return { format, contents: serializefile(file, format), file };
4149
- }
4150
-
4151
- /** Validates and loads one workflow file: the format version must match, the workflow must compose through the full grammar and every packed template must normalize; the loaded record grades unreviewed until the user approves it. */
4152
- export function importworkflow(input: {
4153
- contents: string;
4154
- format?: exportformat;
4155
- now?: number;
4156
- kindallowed?: (kind: string) => boolean;
4157
- riskof?: (kind: string) => actionrisk;
4158
- }): { record: workflowrecord; templates: steptemplate[]; file: workflowfile } {
4159
- const format = input.format ?? (input.contents.trimStart().startsWith("{") ? "json" : "yaml");
4160
- const parsed = parsefile(input.contents, format);
4161
- if (parsed.format !== workflowfileversion)
4162
- throw new Error(
4163
- `The workflow file format ${String(parsed.format)} is not the reviewed format ${workflowfileversion}.`,
4164
- );
4165
- const candidate = parsed.workflow;
4166
- if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
4167
- throw new Error("The workflow file carries no workflow record.");
4168
- const fields = candidate as unknown as Record<string, unknown>;
4169
- const stepsvalue = fields.steps;
4170
- if (!Array.isArray(stepsvalue) || stepsvalue.length === 0)
4171
- throw new Error("An imported workflow needs at least one step.");
4172
- const steps: Array<workflowstep | blockinvocation> = [];
4173
- for (const entry of stepsvalue) {
4174
- const step = workflowstepof(entry);
4175
- if (step) {
4176
- steps.push(step);
4177
- continue;
4178
- }
4179
- throw new Error("Every imported workflow entry must be a reviewed step.");
4180
- }
4181
- const composed = composeworkflow({
4182
- id: typeof fields.id === "string" && fields.id.trim() !== "" ? fields.id : crypto.randomUUID(),
4183
- name: typeof fields.name === "string" ? fields.name : "",
4184
- version: typeof fields.version === "number" ? fields.version : 1,
4185
- origins: Array.isArray(fields.origins)
4186
- ? fields.origins.filter((origin): origin is string => typeof origin === "string")
4187
- : [],
4188
- steps,
4189
- now: input.now ?? Date.now(),
4190
- ...(input.kindallowed !== undefined ? { kindallowed: input.kindallowed } : {}),
4191
- ...(input.riskof !== undefined ? { riskof: input.riskof } : {}),
4192
- });
4193
- const templatesvalue = parsed.templates;
4194
- if (templatesvalue !== undefined && !Array.isArray(templatesvalue))
4195
- throw new Error("The packed templates of the workflow file must be a list.");
4196
- const templates: steptemplate[] = [];
4197
- for (const entry of templatesvalue ?? []) {
4198
- const template = steptemplateof(entry);
4199
- if (!template) throw new Error("A packed template of the workflow file does not carry one reviewed step.");
4200
- templates.push(template);
4201
- }
4202
- const record: workflowrecord = { ...composed, reviewstate: "pending" };
4203
- return { record, templates, file: { ...parsed, workflow: record } };
4204
- }
4205
-
4206
- /** Wires one nested parameter into a block invocation of the canvas: the parameter replaces a same named one and the default binds into the block scope once the run opens it. */
4207
- export function bindparam(model: editormodel, blockname: string, param: nestedparam): editormodel {
4208
- if (!/^[a-z][a-z0-9]*$/.test(param.name)) throw new Error("The nested parameter name must be a lowercase word.");
4209
- const index = model.nodes.findIndex((node) => node.invocation?.block === blockname);
4210
- if (index < 0) throw new Error(`No block invocation of ${blockname} sits on the canvas.`);
4211
- const node = model.nodes[index] as editornode;
4212
- const invocation = node.invocation as blockinvocation;
4213
- const params = [...(invocation.params ?? []).filter((existing) => existing.name !== param.name), { ...param }];
4214
- const nodes = model.nodes.map((candidate, position) =>
4215
- position === index ? { invocation: { ...invocation, params }, x: candidate.x, y: candidate.y } : candidate,
4216
- );
4217
- const next: editormodel = { ...model, nodes };
4218
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
4219
- }
4220
-
4221
- /** Answers whether one origin matches a reviewed override pattern: an exact origin or a `*` subdomain glob of an https origin. The glob walks through plain string comparisons, because a pattern assembled into a regex could rebuild metacharacters the review never saw; the star matches exactly one host label, so `https://*.example.com` matches `https://sub.example.com` and never `https://example.com` or `https://a.b.example.com`. */
4222
- function originmatches(pattern: string, origin: string): boolean {
4223
- if (pattern === origin) return true;
4224
- const scheme = "https://";
4225
- const starprefix = scheme + "*."; /* the reviewed glob shape: a star over exactly one host label */
4226
- if (!pattern.startsWith(starprefix)) return false;
4227
- if (!origin.startsWith(scheme)) return false;
4228
- const suffix = pattern.slice(scheme.length + 1); /* the `.example.com` tail after the star label */
4229
- const host = origin.slice(scheme.length);
4230
- if (host.length <= suffix.length || !host.endsWith(suffix)) return false;
4231
- const label = host.slice(0, host.length - suffix.length);
4232
- return label.length > 0 && !label.includes(".");
4233
- }
4234
-
4235
- /** Applies one per site policy override to a workflow: the deltas adjust only the reviewed knobs — loop bounds, step and run timeouts, element wait timeouts and delay bases — of the steps whose workflow origins match the override pattern. */
4236
- export function applyoverride(record: workflowrecord, override: siteoverride): workflowrecord {
4237
- const matching = record.origins.filter((origin) => originmatches(override.pattern, origin));
4238
- if (matching.length === 0)
4239
- throw new Error(
4240
- `The override pattern ${override.pattern} matches none of the workflow origins ${record.origins.join(", ")}.`,
4241
- );
4242
- const knobs = new Set(["loopbound", "stepms", "runms", "waitms", "delaybase"]);
4243
- for (const knob of Object.keys(override.deltas)) {
4244
- if (!knobs.has(knob))
4245
- throw new Error(`The override knob ${knob} is not one of the reviewed knobs: ${[...knobs].join(", ")}.`);
4246
- if (
4247
- typeof override.deltas[knob] !== "number" ||
4248
- !Number.isFinite(override.deltas[knob]) ||
4249
- (override.deltas[knob] as number) <= 0
4250
- )
4251
- throw new Error(`The override delta of ${knob} must be a positive number with no code ceiling.`);
4252
- }
4253
- const apply = (step: workflowstep): workflowstep => {
4254
- if (Object.keys(override.deltas).length === 0) return step;
4255
- let payload: Record<string, unknown> = {};
4256
- try {
4257
- payload = step.options !== undefined ? (JSON.parse(step.options) as Record<string, unknown>) : {};
4258
- } catch {
4259
- payload = {};
4260
- }
4261
- const bodyof = (key: string): Record<string, unknown> =>
4262
- payload[key] !== undefined && typeof payload[key] === "object" && !Array.isArray(payload[key])
4263
- ? (payload[key] as Record<string, unknown>)
4264
- : {};
4265
- if (override.deltas.loopbound !== undefined && ["loop", "repeatuntil", "whileloop"].includes(step.kind)) {
4266
- const body = bodyof(step.kind);
4267
- body.bound = override.deltas.loopbound;
4268
- payload[step.kind] = body;
4269
- }
4270
- if ((override.deltas.stepms !== undefined || override.deltas.runms !== undefined) && step.kind === "trycatch") {
4271
- const body = bodyof("trycatch");
4272
- const timeout =
4273
- body.timeout !== undefined && typeof body.timeout === "object" && !Array.isArray(body.timeout)
4274
- ? (body.timeout as Record<string, unknown>)
4275
- : {};
4276
- if (override.deltas.stepms !== undefined) timeout.stepms = override.deltas.stepms;
4277
- if (override.deltas.runms !== undefined) timeout.runms = override.deltas.runms;
4278
- body.timeout = timeout;
4279
- payload.trycatch = body;
4280
- }
4281
- if (override.deltas.waitms !== undefined && step.kind === "waitelement") {
4282
- payload.timeout = override.deltas.waitms;
4283
- }
4284
- if (override.deltas.delaybase !== undefined && step.kind === "delay") {
4285
- payload.base = override.deltas.delaybase;
4286
- }
4287
- const changed = Object.keys(payload).length > 0;
4288
- return changed ? { ...step, options: JSON.stringify(payload) } : step;
4289
- };
4290
- return { ...record, steps: record.steps.map(apply) };
4291
- }
4292
-
4293
- /** Wires one typed binding edge from the output socket of an earlier step into the input socket of a later step; a backwards edge refuses so no cycle forms. */
4294
- export function addedge(model: editormodel, edge: editoredge): editormodel {
4295
- const from = model.nodes.findIndex((node) => nodeidof(node) === edge.from);
4296
- const to = model.nodes.findIndex((node) => nodeidof(node) === edge.to);
4297
- if (from < 0) throw new Error(`The canvas edge references the unknown source step ${edge.from}.`);
4298
- if (to < 0) throw new Error(`The canvas edge references the unknown target step ${edge.to}.`);
4299
- if (from >= to)
4300
- throw new Error(
4301
- `The canvas edge of ${edge.variable} would run backwards from ${edge.from} into ${edge.to} and form a cycle.`,
4302
- );
4303
- if (!/^[a-z][a-z0-9]*$/.test(edge.variable)) throw new Error("The bound variable name must be a lowercase word.");
4304
- const edges = [
4305
- ...model.edges.filter(
4306
- (candidate) =>
4307
- !(candidate.from === edge.from && candidate.to === edge.to && candidate.variable === edge.variable),
4308
- ),
4309
- { ...edge, ...(edge.path !== undefined ? { path: edge.path } : {}) },
4310
- ];
4311
- const next: editormodel = { ...model, edges };
4312
- return withundo(model, next);
4313
- }
4314
-
4315
- /** Removes one typed binding edge of the canvas by its source, target and variable. */
4316
- export function removeedge(model: editormodel, from: string, to: string, variable: string): editormodel {
4317
- const edges = model.edges.filter(
4318
- (candidate) => !(candidate.from === from && candidate.to === to && candidate.variable === variable),
4319
- );
4320
- if (edges.length === model.edges.length) throw new Error(`No canvas edge of ${variable} links ${from} into ${to}.`);
4321
- const next: editormodel = { ...model, edges };
4322
- return withundo(model, next);
4323
- }
4324
-
4325
- /** Removes one canvas node with every edge attached to it; the undo stack keeps the removal reversible. */
4326
- export function removenode(model: editormodel, nodeid: string): editormodel {
4327
- const index = model.nodes.findIndex((node) => nodeidof(node) === nodeid);
4328
- if (index < 0) throw new Error(`No canvas node matches ${nodeid}.`);
4329
- const nodes = model.nodes.filter((_, position) => position !== index);
4330
- const edges = model.edges.filter((edge) => edge.from !== nodeid && edge.to !== nodeid);
4331
- const next: editormodel = { ...model, nodes, edges };
4332
- return withundo(model, { ...next, minimap: renderminimap(next).minimap });
4333
- }
4334
-
4335
- /** Steps one canvas edit back: the last undo snapshot becomes the current model and the edited model waits on the redo stack. */
4336
- export function undoedit(model: editormodel): editormodel {
4337
- const undo = model.undo ?? [];
4338
- if (undo.length === 0) return model;
4339
- const previous = undo[undo.length - 1] as editormodel;
4340
- const current = snapshotof(model);
4341
- return { ...previous, undo: undo.slice(0, -1), redo: [...(model.redo ?? []), current] };
4342
- }
4343
-
4344
- /** Steps one canvas edit forward again after an undo: the newest redo snapshot returns as the current model. */
4345
- export function redoedit(model: editormodel): editormodel {
4346
- const redo = model.redo ?? [];
4347
- if (redo.length === 0) return model;
4348
- const next = redo[redo.length - 1] as editormodel;
4349
- const current = snapshotof(model);
4350
- return { ...next, redo: redo.slice(0, -1), undo: [...(model.undo ?? []), current] };
4351
- }
4352
-
4353
- /** Serializes one workflow file into the reviewed json or yaml format; the yaml writer emits the documented subset of quoted scalars, mappings and block sequences the reader parses back. */
4354
- function serializefile(file: workflowfile, format: exportformat): string {
4355
- if (format === "json") return JSON.stringify(file, null, 2);
4356
- return yamlvalue(file, 0).join("\n") + "\n";
4357
- }
4358
-
4359
- /** Parses one workflow file from json or the documented yaml subset; every structural violation refuses the import. */
4360
- function parsefile(contents: string, format: exportformat): workflowfile {
4361
- if (format === "json") {
4362
- const parsed: unknown = JSON.parse(contents);
4363
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
4364
- throw new Error("The workflow file is not a json object.");
4365
- return parsed as workflowfile;
4366
- }
4367
- const lines = contents
4368
- .split(/\r?\n/)
4369
- .map((line) => line.replace(/\t/g, " "))
4370
- .filter((line) => line.trim() !== "" && !line.trim().startsWith("#"));
4371
- if (lines.length === 0) throw new Error("The yaml workflow file is empty.");
4372
- const { value, next } = yamlblock(lines, 0, indentof(lines[0] as string));
4373
- if (next < lines.length) throw new Error("The yaml workflow file carries content outside the documented subset.");
4374
- if (!value || typeof value !== "object" || Array.isArray(value))
4375
- throw new Error("The yaml workflow file is not a mapping.");
4376
- return value as workflowfile;
4377
- }
4378
-
4379
- /** Measures the leading spaces of one line. */
4380
- function indentof(line: string): number {
4381
- const match = /^ */.exec(line);
4382
- return match ? match[0].length : 0;
4383
- }
4384
-
4385
- /** Renders one scalar of the yaml subset: strings quote with json escaping so no scalar ever confuses the reader. */
4386
- function yamlscalar(value: unknown): string {
4387
- if (value === null || value === undefined) return "null";
4388
- if (typeof value === "number" || typeof value === "boolean") return String(value);
4389
- return JSON.stringify(String(value));
4390
- }
4391
-
4392
- /** Renders any value of a workflow file into yaml lines of the documented subset. */
4393
- function yamlvalue(value: unknown, indent: number): string[] {
4394
- const pad = " ".repeat(indent);
4395
- if (value === null || value === undefined || typeof value !== "object") return [`${pad}${yamlscalar(value)}`];
4396
- if (Array.isArray(value)) {
4397
- if (value.length === 0) return [`${pad}[]`];
4398
- const lines: string[] = [];
4399
- for (const item of value) {
4400
- if (item !== null && typeof item === "object") {
4401
- lines.push(`${pad}-`);
4402
- lines.push(...yamlvalue(item, indent + 2));
4403
- } else {
4404
- lines.push(`${pad}- ${yamlscalar(item)}`);
4405
- }
4406
- }
4407
- return lines;
4408
- }
4409
- const entries = Object.entries(value as Record<string, unknown>);
4410
- if (entries.length === 0) return [`${pad}{}`];
4411
- const lines: string[] = [];
4412
- for (const [key, entry] of entries) {
4413
- if (entry !== null && typeof entry === "object") {
4414
- if (Array.isArray(entry) && entry.length === 0) {
4415
- lines.push(`${pad}${key}: []`);
4416
- continue;
4417
- }
4418
- if (!Array.isArray(entry) && Object.keys(entry as Record<string, unknown>).length === 0) {
4419
- lines.push(`${pad}${key}: {}`);
4420
- continue;
4421
- }
4422
- lines.push(`${pad}${key}:`);
4423
- lines.push(...yamlvalue(entry, indent + 2));
4424
- } else {
4425
- lines.push(`${pad}${key}: ${yamlscalar(entry)}`);
4426
- }
4427
- }
4428
- return lines;
4429
- }
4430
-
4431
- /** Parses one yaml block of the documented subset into its value starting at the reviewed line index and indentation. */
4432
- function yamlblock(lines: string[], start: number, indent: number): { value: unknown; next: number } {
4433
- const first = lines[start] as string;
4434
- if (/^\s*-\s/.test(first) || /^\s*-$/.test(first)) {
4435
- const items: unknown[] = [];
4436
- let index = start;
4437
- while (index < lines.length) {
4438
- const line = lines[index] as string;
4439
- if (indentof(line) !== indent || !/^\s*-\s?/.test(line)) break;
4440
- const rest = line.slice(indent + 1).trim();
4441
- if (rest !== "") {
4442
- items.push(yamlscalarvalue(rest));
4443
- index += 1;
4444
- continue;
4445
- }
4446
- const nested = yamlblock(lines, index + 1, indent + 2);
4447
- items.push(nested.value);
4448
- index = nested.next;
4449
- }
4450
- return { value: items, next: index };
4451
- }
4452
- const mapping: Record<string, unknown> = {};
4453
- let index = start;
4454
- while (index < lines.length) {
4455
- const line = lines[index] as string;
4456
- if (indentof(line) !== indent) break;
4457
- const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s(.*))?$/.exec(line.slice(indent));
4458
- if (!match) break;
4459
- const key = match[1] as string;
4460
- const rest = match[2];
4461
- if (rest !== undefined && rest !== "") {
4462
- if (rest === "[]") {
4463
- mapping[key] = [];
4464
- index += 1;
4465
- continue;
4466
- }
4467
- if (rest === "{}") {
4468
- mapping[key] = {};
4469
- index += 1;
4470
- continue;
4471
- }
4472
- mapping[key] = yamlscalarvalue(rest);
4473
- index += 1;
4474
- continue;
4475
- }
4476
- const nested = yamlblock(lines, index + 1, indent + 2);
4477
- mapping[key] = nested.value;
4478
- index = nested.next;
4479
- }
4480
- if (index === start) throw new Error("The yaml workflow file left the documented subset.");
4481
- return { value: mapping, next: index };
4482
- }
4483
-
4484
- /** Parses one quoted, numeric, boolean or null scalar of the yaml subset. */
4485
- function yamlscalarvalue(text: string): unknown {
4486
- if (text.startsWith('"')) {
4487
- const parsed: unknown = JSON.parse(text);
4488
- return typeof parsed === "string" ? parsed : text;
4489
- }
4490
- if (text === "true") return true;
4491
- if (text === "false") return false;
4492
- if (text === "null") return null;
4493
- if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
4494
- return text;
4495
- }