@wenathlan/devthink 2.0.12 → 2.0.14

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 +20 -0
  2. package/accounts.ts +2087 -0
  3. package/agent.ts +2331 -0
  4. package/antigravity.ts +2139 -0
  5. package/apifreeze.ts +4146 -0
  6. package/auth.ts +642 -0
  7. package/background.ts +16837 -0
  8. package/bridge.ts +2080 -0
  9. package/build.ts +54 -0
  10. package/bun.ts +26 -0
  11. package/capture.ts +1614 -0
  12. package/checksecrets.ts +72 -0
  13. package/cjs.ts +9 -0
  14. package/cli.ts +3500 -0
  15. package/commands.ts +1101 -0
  16. package/companion.ts +450 -0
  17. package/compatibility.ts +37 -0
  18. package/config.ts +3137 -0
  19. package/constants.ts +943 -0
  20. package/core.ts +1473 -0
  21. package/crossbrowser.ts +1359 -0
  22. package/dashdone.ts +605 -0
  23. package/data.ts +943 -0
  24. package/database.ts +133 -0
  25. package/debug.ts +850 -0
  26. package/deno.ts +27 -0
  27. package/devthink.ts +707 -0
  28. package/dist/accounts.d.ts +409 -0
  29. package/dist/agent.d.ts +837 -0
  30. package/dist/antigravity.d.ts +380 -0
  31. package/dist/apifreeze.d.ts +113 -0
  32. package/dist/auth.d.ts +224 -0
  33. package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
  34. package/dist/capture.d.ts +595 -0
  35. package/dist/cjs.d.ts +6 -0
  36. package/{cli.js → dist/cli.js} +2 -2
  37. package/dist/commands.d.ts +310 -0
  38. package/dist/compatibility.d.ts +17 -0
  39. package/dist/config.d.ts +1853 -0
  40. package/dist/constants.d.ts +637 -0
  41. package/dist/core.d.ts +361 -0
  42. package/dist/data.d.ts +330 -0
  43. package/dist/database.d.ts +24 -0
  44. package/dist/debug.d.ts +270 -0
  45. package/dist/engine.d.ts +74 -0
  46. package/dist/environments.d.ts +244 -0
  47. package/dist/evidence.d.ts +277 -0
  48. package/dist/export.d.ts +311 -0
  49. package/dist/fingerprint.d.ts +594 -0
  50. package/dist/flow.d.ts +300 -0
  51. package/dist/gates.d.ts +103 -0
  52. package/dist/identity.d.ts +51 -0
  53. package/dist/ids.d.ts +7 -0
  54. package/dist/llm.d.ts +652 -0
  55. package/dist/models.d.ts +1807 -0
  56. package/dist/net.d.ts +325 -0
  57. package/dist/neutral.d.ts +41 -0
  58. package/dist/oauth.d.ts +703 -0
  59. package/dist/page.d.ts +1166 -0
  60. package/dist/perf.d.ts +481 -0
  61. package/dist/plan.d.ts +134 -0
  62. package/dist/project.d.ts +395 -0
  63. package/dist/providers.d.ts +37 -0
  64. package/dist/quota.d.ts +453 -0
  65. package/dist/recovery.d.ts +621 -0
  66. package/dist/request.d.ts +1492 -0
  67. package/dist/run.d.ts +779 -0
  68. package/dist/runtime.d.ts +212 -0
  69. package/dist/search.d.ts +375 -0
  70. package/dist/security.d.ts +534 -0
  71. package/dist/serve.d.ts +136 -0
  72. package/dist/session.d.ts +296 -0
  73. package/dist/storage.d.ts +53 -0
  74. package/dist/streaming.d.ts +1119 -0
  75. package/dist/swarm.d.ts +328 -0
  76. package/dist/tools.d.ts +170 -0
  77. package/dist/types.d.ts +8357 -0
  78. package/dist/utils.d.ts +82 -0
  79. package/dist/version.d.ts +7 -0
  80. package/dist/versionregistry.d.ts +406 -0
  81. package/dist/views.d.ts +579 -0
  82. package/dist/workbenchsession.d.ts +69 -0
  83. package/dist/workflow.d.ts +649 -0
  84. package/docs/readme.md +92 -0
  85. package/docs/schemas/antigravity.json +192 -0
  86. package/engine.ts +1524 -0
  87. package/environments.ts +1005 -0
  88. package/evidence.ts +643 -0
  89. package/export.ts +889 -0
  90. package/fingerprint.ts +1458 -0
  91. package/flow.ts +1190 -0
  92. package/gates.ts +307 -0
  93. package/gateway.ts +34 -0
  94. package/hardening.ts +191 -0
  95. package/headless.ts +369 -0
  96. package/http.ts +2110 -0
  97. package/identity.ts +246 -0
  98. package/ids.ts +26 -0
  99. package/index.ts +369 -0
  100. package/llm.ts +2682 -0
  101. package/mcp.ts +1835 -0
  102. package/{memory.js → memory.ts} +3834 -2310
  103. package/models.ts +3805 -0
  104. package/modes.ts +43 -0
  105. package/net.ts +1214 -0
  106. package/neutral.ts +362 -0
  107. package/node.ts +26 -0
  108. package/oauth.ts +2216 -0
  109. package/offscreen.ts +170 -0
  110. package/pack.ts +956 -0
  111. package/package.json +175 -82
  112. package/page.ts +7824 -0
  113. package/pagebridge.ts +1116 -0
  114. package/perf.ts +957 -0
  115. package/plan.ts +672 -0
  116. package/plugin.ts +76 -0
  117. package/{policy.js → policy.ts} +5073 -4679
  118. package/prisma.config.ts +27 -0
  119. package/progress.ts +1308 -0
  120. package/project.ts +1291 -0
  121. package/protocol.ts +4187 -0
  122. package/providers.ts +246 -0
  123. package/quota.ts +1887 -0
  124. package/recovery.ts +2433 -0
  125. package/request.ts +4482 -0
  126. package/run.ts +2135 -0
  127. package/runtime.ts +526 -0
  128. package/search.ts +1760 -0
  129. package/security.ts +1673 -0
  130. package/serve.ts +419 -0
  131. package/server.ts +5538 -0
  132. package/session.ts +1162 -0
  133. package/storage.ts +182 -0
  134. package/streaming.ts +4101 -0
  135. package/swarm.ts +884 -0
  136. package/sync.ts +56 -0
  137. package/tests/agentcert.test.ts +128 -0
  138. package/tests/agentfleet.test.ts +214 -0
  139. package/tests/agentreview.test.ts +241 -0
  140. package/tests/agentstream.test.ts +335 -0
  141. package/tests/agentwork.test.ts +356 -0
  142. package/tests/apifreeze.test.ts +290 -0
  143. package/tests/apimap.test.ts +112 -0
  144. package/tests/approvalgate.test.ts +175 -0
  145. package/tests/artifactmanifest.test.ts +204 -0
  146. package/tests/attentionfeed.test.ts +163 -0
  147. package/tests/backgroundruns.test.ts +100 -0
  148. package/tests/batchscheduling.test.ts +131 -0
  149. package/tests/blackboard.test.ts +238 -0
  150. package/tests/bridge.test.ts +1088 -0
  151. package/tests/browsercoverage.test.ts +134 -0
  152. package/tests/browserpolyfills.test.ts +124 -0
  153. package/tests/build.mjs +1260 -0
  154. package/tests/clientauth.test.ts +223 -0
  155. package/tests/clitools.test.ts +578 -0
  156. package/tests/confirmgates.test.ts +190 -0
  157. package/tests/containerpack.test.ts +143 -0
  158. package/tests/controlflow.test.ts +2120 -0
  159. package/tests/coordination.test.ts +345 -0
  160. package/tests/copyscreen.test.ts +139 -0
  161. package/tests/core.test.ts +417 -0
  162. package/tests/costcert.test.ts +95 -0
  163. package/tests/crossbrowser.test.ts +115 -0
  164. package/tests/cspaudit.test.ts +79 -0
  165. package/tests/dashdone.test.ts +615 -0
  166. package/tests/datagrid.test.ts +137 -0
  167. package/tests/doccheck.test.ts +107 -0
  168. package/tests/dombatch.test.ts +109 -0
  169. package/tests/emulation.test.ts +282 -0
  170. package/tests/environments.test.ts +259 -0
  171. package/tests/evidenceviews.test.ts +88 -0
  172. package/tests/exporttools.test.ts +185 -0
  173. package/tests/firefoxprep.test.ts +134 -0
  174. package/tests/flowlibrary.test.ts +242 -0
  175. package/tests/flowrun.test.ts +303 -0
  176. package/tests/forensics.test.ts +439 -0
  177. package/tests/gateway.test.ts +939 -0
  178. package/tests/headlesslib.test.ts +171 -0
  179. package/tests/httpstream.test.ts +277 -0
  180. package/tests/immutablelog.test.ts +157 -0
  181. package/tests/inboundguard.test.ts +177 -0
  182. package/tests/lazyload.test.ts +72 -0
  183. package/tests/librarymodes.test.ts +623 -0
  184. package/tests/llm.test.ts +696 -0
  185. package/tests/manifest.test.ts +226 -0
  186. package/tests/maskinputs.test.ts +150 -0
  187. package/tests/matrixverify.test.ts +111 -0
  188. package/tests/mavenpack.test.ts +75 -0
  189. package/tests/mcpserver.test.ts +704 -0
  190. package/tests/memory.test.ts +4155 -0
  191. package/tests/memorycare.test.ts +230 -0
  192. package/tests/migrateplan.test.ts +349 -0
  193. package/tests/minimization.test.ts +414 -0
  194. package/tests/modelroute.test.ts +112 -0
  195. package/tests/multiagent.test.ts +440 -0
  196. package/tests/nativebridge.test.ts +807 -0
  197. package/tests/navigation.test.ts +284 -0
  198. package/tests/nugetpack.test.ts +83 -0
  199. package/tests/orchestration.test.ts +449 -0
  200. package/tests/originpolicy.test.ts +405 -0
  201. package/tests/outputcompare.test.ts +97 -0
  202. package/tests/package.test.ts +91 -0
  203. package/tests/pagebridge.test.ts +5909 -0
  204. package/tests/pentest.test.ts +75 -0
  205. package/tests/perfrecords.test.ts +257 -0
  206. package/tests/permdiff.test.ts +85 -0
  207. package/tests/phishguard.test.ts +128 -0
  208. package/tests/pickerviews.test.ts +155 -0
  209. package/tests/pipelines.test.ts +381 -0
  210. package/tests/planlint.test.ts +240 -0
  211. package/tests/planreview.test.ts +383 -0
  212. package/tests/platformtargets.test.ts +56 -0
  213. package/tests/policy.test.ts +8284 -0
  214. package/tests/portability.test.ts +96 -0
  215. package/tests/progress.test.ts +1036 -0
  216. package/tests/promptlibrary.test.ts +116 -0
  217. package/tests/protocol.test.ts +4793 -0
  218. package/tests/publishingpipeline.test.ts +314 -0
  219. package/tests/quickactions.test.ts +164 -0
  220. package/tests/readiness.test.ts +78 -0
  221. package/tests/recipes.test.ts +163 -0
  222. package/tests/redactshots.test.ts +216 -0
  223. package/tests/registernvidiakeys.ts +51 -0
  224. package/tests/relayserve.test.ts +304 -0
  225. package/tests/releasenotes.test.ts +276 -0
  226. package/tests/resilience.test.ts +467 -0
  227. package/tests/resourceaware.test.ts +187 -0
  228. package/tests/runbudget.test.ts +125 -0
  229. package/tests/runhistory.test.ts +299 -0
  230. package/tests/runreplay.test.ts +113 -0
  231. package/tests/runresume.test.ts +184 -0
  232. package/tests/runstate.test.ts +288 -0
  233. package/tests/runtimeadapters.test.ts +251 -0
  234. package/tests/safariskeleton.test.ts +115 -0
  235. package/tests/sandboxframe.test.ts +196 -0
  236. package/tests/sbom.test.ts +105 -0
  237. package/tests/secretvault.test.ts +188 -0
  238. package/tests/selcache.test.ts +111 -0
  239. package/tests/sessioninterface.test.ts +791 -0
  240. package/tests/sessions.test.ts +591 -0
  241. package/tests/siteprefs.test.ts +228 -0
  242. package/tests/snapshotdelta.test.ts +165 -0
  243. package/tests/statusviews.test.ts +237 -0
  244. package/tests/stepmeter.test.ts +200 -0
  245. package/tests/streamparse.test.ts +163 -0
  246. package/tests/stylepruning.test.ts +90 -0
  247. package/tests/surfaces.test.ts +282 -0
  248. package/tests/sweep.test.ts +78 -0
  249. package/tests/syncbridge.test.ts +164 -0
  250. package/tests/taskqueue.test.ts +206 -0
  251. package/tests/toolcalls.test.ts +336 -0
  252. package/tests/toolcatalog.test.ts +201 -0
  253. package/tests/torture-agent.test.ts +706 -0
  254. package/tests/torture-auth.test.ts +461 -0
  255. package/tests/torture-commands.test.ts +895 -0
  256. package/tests/torture-data.test.ts +923 -0
  257. package/tests/torture-environments.test.ts +285 -0
  258. package/tests/torture-export.test.ts +263 -0
  259. package/tests/torture-http.test.ts +1379 -0
  260. package/tests/torture-llm.test.ts +808 -0
  261. package/tests/torture-memory.test.ts +1084 -0
  262. package/tests/torture-plan.test.ts +829 -0
  263. package/tests/torture-run.test.ts +626 -0
  264. package/tests/torture-session.test.ts +928 -0
  265. package/tests/torture-swarm.test.ts +1245 -0
  266. package/tests/torture-tools.test.ts +964 -0
  267. package/tests/torture-workflow.test.ts +2354 -0
  268. package/tests/tourviews.test.ts +52 -0
  269. package/tests/transparency.test.ts +147 -0
  270. package/tests/trigger.test.ts +941 -0
  271. package/tests/v1sunset.test.ts +428 -0
  272. package/tests/virtlist.test.ts +94 -0
  273. package/tests/vision.test.ts +540 -0
  274. package/tests/vsixpack.test.ts +145 -0
  275. package/tests/webapi.test.ts +523 -0
  276. package/tests/workflow.test.ts +1785 -0
  277. package/tests/workfloweditor.test.ts +759 -0
  278. package/tests/xpipack.test.ts +128 -0
  279. package/tools.ts +734 -0
  280. package/types.ts +9908 -0
  281. package/umd.ts +9 -0
  282. package/utils.ts +249 -0
  283. package/version.ts +8 -0
  284. package/versionregistry.ts +1310 -0
  285. package/views.ts +2153 -0
  286. package/vitest.config.ts +12 -0
  287. package/web/App.tsx +78 -0
  288. package/web/README.md +47 -0
  289. package/web/capacitor.config.ts +46 -0
  290. package/web/const.ts +19 -0
  291. package/web/control.shell.tsx +66 -0
  292. package/web/dashboardpage.ts +554 -0
  293. package/web/db.ts +493 -0
  294. package/web/design.html +541 -0
  295. package/web/gatewayview/Gateway.tsx +345 -0
  296. package/web/gatewayview/config.ts +1042 -0
  297. package/web/gatewayview/definition.ts +174 -0
  298. package/web/icons.ts +15 -0
  299. package/{manifest.json → web/manifest.json} +1 -1
  300. package/web/optionspage.ts +691 -0
  301. package/web/popup.ts +1651 -0
  302. package/web/schema.prisma +176 -0
  303. package/web/sidepanel.ts +7941 -0
  304. package/web/sitemanifest.json +13 -0
  305. package/web/terminal-ui.tsx +622 -0
  306. package/web/transparencypage.ts +541 -0
  307. package/web/vite.config.ts +39 -0
  308. package/web/workspace.ts +23 -0
  309. package/workbenchmemory.ts +104 -0
  310. package/workbenchsession.ts +304 -0
  311. package/workflow.ts +4495 -0
  312. package/bridge.d.ts.map +0 -1
  313. package/bridge.js +0 -1877
  314. package/bun.d.ts.map +0 -1
  315. package/bun.js +0 -52329
  316. package/caps/background.json +0 -712
  317. package/caps/cli.json +0 -360
  318. package/caps/library.json +0 -3611
  319. package/caps/mcp.json +0 -126
  320. package/caps/pagebridge.json +0 -377
  321. package/caps/popup.json +0 -51
  322. package/caps/sidepanel.json +0 -522
  323. package/checksums.txt +0 -142
  324. package/cli.d.ts.map +0 -1
  325. package/companion.d.ts.map +0 -1
  326. package/companion.js +0 -389
  327. package/crossbrowser.d.ts.map +0 -1
  328. package/crossbrowser.js +0 -1191
  329. package/dashdone.d.ts.map +0 -1
  330. package/dashdone.js +0 -368
  331. package/deno.d.ts.map +0 -1
  332. package/deno.js +0 -52325
  333. package/devthink.umd.js +0 -52346
  334. package/fixtures/bun.ts +0 -7
  335. package/fixtures/cjs.js +0 -6
  336. package/fixtures/deno.ts +0 -6
  337. package/fixtures/example-org-pagestate.json +0 -22
  338. package/fixtures/importers/automa-workflow.json +0 -18
  339. package/fixtures/importers/selenium-side.json +0 -20
  340. package/fixtures/importers/tabular-plan.csv +0 -7
  341. package/fixtures/importers/uivision-macro.json +0 -10
  342. package/fixtures/importers/v1-plan.json +0 -13
  343. package/fixtures/mcp-client.mjs +0 -14
  344. package/fixtures/pages/checkout.html +0 -192
  345. package/fixtures/pages/dashboard.html +0 -110
  346. package/fixtures/pages/feed.html +0 -113
  347. package/fixtures/pages/product-grid.html +0 -150
  348. package/fixtures/plans/form-inventory-plan.json +0 -11
  349. package/fixtures/plans/release-notes-plan.json +0 -11
  350. package/fixtures/recipes/agentscompete.json +0 -11
  351. package/fixtures/recipes/agentsforms.json +0 -11
  352. package/fixtures/recipes/agentsmonitor.json +0 -11
  353. package/fixtures/recipes/agentsreview.json +0 -29
  354. package/fixtures/recipes/agentswarm.json +0 -11
  355. package/fixtures/recipes/fillcalendar.json +0 -12
  356. package/fixtures/recipes/fillcheckout.json +0 -13
  357. package/fixtures/recipes/filldependentdropdowns.json +0 -12
  358. package/fixtures/recipes/fillfromcsv.json +0 -13
  359. package/fixtures/recipes/fillloginform.json +0 -12
  360. package/fixtures/recipes/fillmultistepwizard.json +0 -13
  361. package/fixtures/recipes/fillregistration.json +0 -12
  362. package/fixtures/recipes/fillsearchfilters.json +0 -12
  363. package/fixtures/recipes/filltypeahead.json +0 -11
  364. package/fixtures/recipes/monitoralerts.json +0 -12
  365. package/fixtures/recipes/monitoravailability.json +0 -11
  366. package/fixtures/recipes/monitornetworkquiet.json +0 -12
  367. package/fixtures/recipes/monitorpagechanges.json +0 -11
  368. package/fixtures/recipes/monitorpricechange.json +0 -11
  369. package/fixtures/recipes/monitorschedule.json +0 -12
  370. package/fixtures/recipes/scrapedynamicprices.json +0 -13
  371. package/fixtures/recipes/scrapeimagegallery.json +0 -12
  372. package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
  373. package/fixtures/recipes/scrapenestedlists.json +0 -12
  374. package/fixtures/recipes/scrapeproductgrid.json +0 -12
  375. package/fixtures/recipes/scrapereviews.json +0 -12
  376. package/fixtures/recipes/scrapesearchresults.json +0 -12
  377. package/fixtures/recipes/scrapetablepagination.json +0 -12
  378. package/fixtures/recipes/testaccessibility.json +0 -34
  379. package/fixtures/recipes/testconsoleerrors.json +0 -12
  380. package/fixtures/recipes/testdeeplinks.json +0 -12
  381. package/fixtures/recipes/testformvalidation.json +0 -13
  382. package/fixtures/recipes/testlayoutshift.json +0 -12
  383. package/fixtures/recipes/testlinkcheck.json +0 -12
  384. package/fixtures/recipes/testloadtiming.json +0 -12
  385. package/fixtures/recipes/testvisualdiff.json +0 -12
  386. package/fixtures/workflows/changelog-digest-workflow.json +0 -13
  387. package/fixtures/workflows/form-survey-workflow.json +0 -13
  388. package/gallery.json +0 -706
  389. package/hardening.d.ts.map +0 -1
  390. package/hardening.js +0 -188
  391. package/headless.d.ts.map +0 -1
  392. package/headless.js +0 -11522
  393. package/http.d.ts.map +0 -1
  394. package/http.js +0 -2165
  395. package/index.cjs +0 -54682
  396. package/index.d.ts.map +0 -1
  397. package/index.js +0 -52310
  398. package/index.neutral.js +0 -46343
  399. package/mcp.d.ts.map +0 -1
  400. package/mcp.js +0 -20515
  401. package/memory.d.ts.map +0 -1
  402. package/nativehost.template.json +0 -10
  403. package/node.cjs +0 -54686
  404. package/node.d.ts.map +0 -1
  405. package/pack.d.ts.map +0 -1
  406. package/pack.js +0 -805
  407. package/policy.d.ts.map +0 -1
  408. package/progress.d.ts.map +0 -1
  409. package/progress.js +0 -645
  410. package/protocol.d.ts.map +0 -1
  411. package/protocol.js +0 -8334
  412. package/schemas/audit.schema.json +0 -226
  413. package/schemas/capability.schema.json +0 -113
  414. package/schemas/envelope.schema.json +0 -162
  415. package/schemas/memory.schema.json +0 -51
  416. package/schemas/observation.schema.json +0 -113
  417. package/schemas/plan.schema.json +0 -208
  418. package/schemas/progress.schema.json +0 -126
  419. package/schemas/proposal.schema.json +0 -68
  420. package/schemas/session.schema.json +0 -82
  421. package/schemas/tool.schema.json +0 -94
  422. package/server.d.ts.map +0 -1
  423. package/server.js +0 -28877
  424. package/umd-example.html +0 -18
  425. package/umd.d.ts.map +0 -1
  426. /package/{bun.d.ts → dist/bun.d.ts} +0 -0
  427. /package/{cli.d.ts → dist/cli.d.ts} +0 -0
  428. /package/{companion.d.ts → dist/companion.d.ts} +0 -0
  429. /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
  430. /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
  431. /package/{deno.d.ts → dist/deno.d.ts} +0 -0
  432. /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
  433. /package/{headless.d.ts → dist/headless.d.ts} +0 -0
  434. /package/{http.d.ts → dist/http.d.ts} +0 -0
  435. /package/{index.d.ts → dist/index.d.ts} +0 -0
  436. /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
  437. /package/{memory.d.ts → dist/memory.d.ts} +0 -0
  438. /package/{node.d.ts → dist/node.d.ts} +0 -0
  439. /package/{pack.d.ts → dist/pack.d.ts} +0 -0
  440. /package/{policy.d.ts → dist/policy.d.ts} +0 -0
  441. /package/{progress.d.ts → dist/progress.d.ts} +0 -0
  442. /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
  443. /package/{server.d.ts → dist/server.d.ts} +0 -0
  444. /package/{umd.d.ts → dist/umd.d.ts} +0 -0
@@ -0,0 +1,1785 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ bindvariables,
4
+ blockinvocationof,
5
+ cancelrun,
6
+ composeworkflow,
7
+ delayjitter,
8
+ dryrunworkflow,
9
+ expandblocks,
10
+ expressioneval,
11
+ expressionof,
12
+ newworkflowrun,
13
+ pauserun,
14
+ popscope,
15
+ pushscope,
16
+ regexextract,
17
+ regexruleof,
18
+ resolvevariable,
19
+ runstep,
20
+ runworkflow,
21
+ steptemplateof,
22
+ validateworkflow,
23
+ waitelementplan,
24
+ watchdogpass,
25
+ workflowblockof,
26
+ workflowkinds,
27
+ workflowstepof,
28
+ } from "../workflow.js";
29
+ import {
30
+ actionrisk,
31
+ canexecute,
32
+ dryrunprojection,
33
+ isworkflowkind,
34
+ validatestep,
35
+ validateregexrule,
36
+ workflowgate,
37
+ } from "../policy.js";
38
+ import { parseproposal, parseworkflowproposal, workflowoutcome, workflowreport } from "../protocol.js";
39
+ import { recordworkflow, workflowevidences, workflowshare } from "../progress.js";
40
+ import { sessionmemory } from "../memory.js";
41
+ import { protocolversion } from "../types.js";
42
+ import type {
43
+ agentplan,
44
+ agentsession,
45
+ runlogentry,
46
+ stepoutcome,
47
+ toolstep,
48
+ variablescope,
49
+ workflowrun,
50
+ } from "../types.js";
51
+
52
+ const now = 1_800_000_000_000;
53
+ const session: agentsession = {
54
+ id: "sess",
55
+ tabid: 4,
56
+ origin: "https://example.com",
57
+ startedat: now - 1000,
58
+ expiresat: now + 600_000,
59
+ grants: ["https://example.com"],
60
+ };
61
+ const plan: agentplan = {
62
+ id: "run",
63
+ objective: "Run the reviewed workflow",
64
+ origin: "https://example.com",
65
+ steps: [],
66
+ createdat: now - 2000,
67
+ expiresat: now + 600_000,
68
+ state: "approved",
69
+ };
70
+
71
+ function kindallowed(kind: string): boolean {
72
+ try {
73
+ actionrisk(kind as toolstep["kind"]);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function riskof(kind: string): "read" | "interaction" | "sensitive" {
81
+ return actionrisk(kind as toolstep["kind"]);
82
+ }
83
+
84
+ /** Composes one small workflow of read steps for the engine suites. */
85
+ function readworkflow(
86
+ steps: Array<Record<string, unknown>> = [
87
+ { id: "s1", kind: "readtext", label: "Read the heading", target: "h1" },
88
+ { id: "s2", kind: "wait", label: "Wait a beat", value: "10" },
89
+ ],
90
+ ) {
91
+ return composeworkflow({
92
+ id: "wf1",
93
+ name: "reader",
94
+ version: 1,
95
+ origins: ["https://example.com"],
96
+ steps: steps.map((entry) => workflowstepof(entry) as never),
97
+ now,
98
+ kindallowed,
99
+ riskof,
100
+ });
101
+ }
102
+
103
+ class fakeadapter {
104
+ private readonly data = new Map<string, unknown>();
105
+ async get<T>(key: string): Promise<T | undefined> {
106
+ return this.data.get(key) as T | undefined;
107
+ }
108
+ async set<T>(key: string, value: T): Promise<void> {
109
+ this.data.set(key, value);
110
+ }
111
+ }
112
+
113
+ describe("workflow kinds and grammars", () => {
114
+ it("lists the workflow kinds and marks every one inside the risk table", () => {
115
+ expect(workflowkinds).toEqual([
116
+ "composeworkflow",
117
+ "savetemplate",
118
+ "runworkflow",
119
+ "dryrun",
120
+ "delay",
121
+ "waitelement",
122
+ "compute",
123
+ "extractvars",
124
+ ]);
125
+ for (const kind of workflowkinds) expect(isworkflowkind(kind as toolstep["kind"])).toBe(true);
126
+ expect(isworkflowkind("click")).toBe(false);
127
+ expect(actionrisk("runworkflow")).toBe("sensitive");
128
+ expect(actionrisk("dryrun")).toBe("read");
129
+ });
130
+
131
+ it("normalizes workflow steps, blocks, invocations, bindings, expressions and regex rules", () => {
132
+ const step = workflowstepof({
133
+ id: "s1",
134
+ kind: "readtext",
135
+ label: "Read the heading",
136
+ target: "h1",
137
+ bindings: [{ variable: "title", kind: "string", stepid: "s0", path: "details.text" }],
138
+ expression: {
139
+ left: { ref: "title" },
140
+ right: { literal: 2 },
141
+ operator: "concat",
142
+ result: "label",
143
+ resultkind: "string",
144
+ },
145
+ extract: { pattern: "(?<code>[a-z]+)", flags: "g", groups: ["code"] },
146
+ });
147
+ if (!step) throw new Error("The reviewed workflow step did not normalize.");
148
+ expect(step.bindings).toHaveLength(1);
149
+ expect(step.expression?.operator).toBe("concat");
150
+ expect(step.extract?.groups).toEqual(["code"]);
151
+ expect(workflowstepof({ id: "s1", kind: "ReadText", label: "Mixed case" })).toBeUndefined();
152
+ expect(workflowstepof({ id: "", kind: "wait", label: "Empty id" })).toBeUndefined();
153
+ expect(
154
+ workflowstepof({
155
+ id: "s1",
156
+ kind: "wait",
157
+ label: "Bad binding",
158
+ bindings: [{ variable: "x", kind: "string", stepid: "" }],
159
+ }),
160
+ ).toBeUndefined();
161
+ expect(blockinvocationof({ block: "login", label: "Run the login block" })).toEqual({
162
+ block: "login",
163
+ label: "Run the login block",
164
+ });
165
+ expect(blockinvocationof({ block: "", label: "Empty" })).toBeUndefined();
166
+ expect(
167
+ workflowblockof({
168
+ name: "login",
169
+ label: "Login block",
170
+ steps: [{ id: "b1", kind: "click", label: "Click submit", target: "#submit" }],
171
+ }),
172
+ ).toBeDefined();
173
+ expect(workflowblockof({ name: "Login", label: "Upper case name", steps: [] })).toBeUndefined();
174
+ expect(
175
+ steptemplateof({
176
+ id: "t1",
177
+ name: "shared click",
178
+ origin: "https://example.com",
179
+ step: { id: "s1", kind: "click", label: "Click", target: "#x" },
180
+ sharedat: now,
181
+ }),
182
+ ).toBeDefined();
183
+ expect(
184
+ steptemplateof({
185
+ id: "t1",
186
+ name: "shared click",
187
+ origin: "https://example.com",
188
+ step: { kind: "click" },
189
+ sharedat: now,
190
+ }),
191
+ ).toBeUndefined();
192
+ expect(
193
+ expressionof({
194
+ left: { ref: "count" },
195
+ right: { literal: 1 },
196
+ operator: "add",
197
+ result: "total",
198
+ resultkind: "number",
199
+ }),
200
+ ).toBeDefined();
201
+ expect(
202
+ expressionof({ left: { ref: "count" }, operator: "explode", result: "total", resultkind: "number" }),
203
+ ).toBeUndefined();
204
+ expect(regexruleof({ pattern: "(?<code>[a-z]+)", flags: "g", groups: ["code"] })).toBeDefined();
205
+ expect(regexruleof({ pattern: "(", flags: "", groups: [] })).toBeDefined();
206
+ expect(regexruleof({ pattern: "x", flags: "q", groups: [] })).toBeUndefined();
207
+ expect(regexruleof({ pattern: "x", flags: "", groups: ["9bad"] })).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ describe("composeworkflow", () => {
212
+ it("validates every grammar rule before freezing the record", () => {
213
+ expect(() =>
214
+ composeworkflow({ name: " ", version: 1, origins: ["https://example.com"], steps: [], now, kindallowed, riskof }),
215
+ ).toThrow("name");
216
+ expect(() =>
217
+ composeworkflow({ name: "w", version: 0, origins: ["https://example.com"], steps: [], now, kindallowed, riskof }),
218
+ ).toThrow("version");
219
+ expect(() =>
220
+ composeworkflow({
221
+ name: "w",
222
+ version: 1.5,
223
+ origins: ["https://example.com"],
224
+ steps: [],
225
+ now,
226
+ kindallowed,
227
+ riskof,
228
+ }),
229
+ ).toThrow("version");
230
+ expect(() => composeworkflow({ name: "w", version: 1, origins: [], steps: [], now, kindallowed, riskof })).toThrow(
231
+ "origin",
232
+ );
233
+ expect(() =>
234
+ composeworkflow({ name: "w", version: 1, origins: ["http://example.com"], steps: [], now, kindallowed, riskof }),
235
+ ).toThrow("HTTPS");
236
+ expect(() =>
237
+ composeworkflow({ name: "w", version: 1, origins: ["https://example.com"], steps: [], now, kindallowed, riskof }),
238
+ ).toThrow("at least one");
239
+ expect(() =>
240
+ composeworkflow({
241
+ name: "w",
242
+ version: 1,
243
+ origins: ["https://example.com"],
244
+ steps: [{ id: "s1", kind: "explode", label: "Unknown kind" } as never],
245
+ now,
246
+ kindallowed,
247
+ riskof,
248
+ }),
249
+ ).toThrow("not a reviewed action kind");
250
+ expect(() =>
251
+ composeworkflow({
252
+ name: "w",
253
+ version: 1,
254
+ origins: ["https://example.com"],
255
+ steps: [{ id: "s1", kind: "wait", label: "Wait" } as never],
256
+ blocks: [
257
+ { name: "a", label: "A", steps: [] },
258
+ { name: "a", label: "A again", steps: [] },
259
+ ],
260
+ now,
261
+ kindallowed,
262
+ riskof,
263
+ }),
264
+ ).toThrow("unique");
265
+ expect(() =>
266
+ composeworkflow({
267
+ name: "w",
268
+ version: 1,
269
+ origins: ["https://example.com"],
270
+ steps: [
271
+ {
272
+ id: "s1",
273
+ kind: "wait",
274
+ label: "Wait",
275
+ bindings: [{ variable: "v", kind: "string", stepid: "missing" }],
276
+ } as never,
277
+ ],
278
+ now,
279
+ kindallowed,
280
+ riskof,
281
+ }),
282
+ ).toThrow("unknown step");
283
+ const record = readworkflow();
284
+ expect(record.id).toBe("wf1");
285
+ expect(record.risk).toBe("read");
286
+ expect(Object.isFrozen(record)).toBe(true);
287
+ expect(Object.isFrozen(record.steps[0])).toBe(true);
288
+ expect(() => {
289
+ (record.steps as unknown as { push(item: unknown): void }[])[0]?.push;
290
+ }).not.toThrow();
291
+ });
292
+
293
+ it("grades workflows that write, submit or navigate as sensitive for review", () => {
294
+ const sensitive = composeworkflow({
295
+ name: "writer",
296
+ version: 1,
297
+ origins: ["https://example.com"],
298
+ steps: [{ id: "s1", kind: "click", label: "Click submit", target: "#submit" } as never],
299
+ now,
300
+ kindallowed,
301
+ riskof,
302
+ });
303
+ expect(sensitive.risk).toBe("sensitive");
304
+ const interaction = composeworkflow({
305
+ name: "hoverer",
306
+ version: 1,
307
+ origins: ["https://example.com"],
308
+ steps: [{ id: "s1", kind: "hover", label: "Hover the menu", target: "#menu" } as never],
309
+ now,
310
+ kindallowed,
311
+ riskof,
312
+ });
313
+ expect(interaction.risk).toBe("interaction");
314
+ });
315
+ });
316
+
317
+ describe("expandblocks", () => {
318
+ it("flattens three levels of nesting with the innermost block carried on every step", () => {
319
+ const inner = workflowblockof({
320
+ name: "inner",
321
+ label: "Inner block",
322
+ steps: [{ id: "i1", kind: "wait", label: "Inner wait", value: "5" }],
323
+ })!;
324
+ const middle = workflowblockof({
325
+ name: "middle",
326
+ label: "Middle block",
327
+ steps: [
328
+ { id: "m1", kind: "readtext", label: "Middle read", target: "p" },
329
+ { block: "inner", label: "Call inner" },
330
+ ],
331
+ })!;
332
+ const outer = workflowblockof({
333
+ name: "outer",
334
+ label: "Outer block",
335
+ steps: [
336
+ { block: "middle", label: "Call middle" },
337
+ { id: "o1", kind: "wait", label: "Outer wait", value: "5" },
338
+ ],
339
+ })!;
340
+ const flat = expandblocks([{ block: "outer", label: "Call outer" }], [inner, middle, outer]);
341
+ expect(flat.map((step) => step.id)).toEqual(["m1", "i1", "o1"]);
342
+ expect(flat.map((step) => step.block)).toEqual(["middle", "inner", "outer"]);
343
+ });
344
+
345
+ it("refuses unknown and cyclic block references and empty expansions", () => {
346
+ expect(() => expandblocks([{ block: "ghost", label: "Missing block" }], [])).toThrow("not defined");
347
+ expect(() =>
348
+ expandblocks(
349
+ [{ block: "loop", label: "Loop" }],
350
+ [workflowblockof({ name: "loop", label: "Loop block", steps: [{ block: "loop", label: "Loop again" }] })!],
351
+ ),
352
+ ).toThrow("recurs");
353
+ expect(() => expandblocks([], [])).toThrow("at least one");
354
+ });
355
+ });
356
+
357
+ describe("validateworkflow", () => {
358
+ it("checks kinds, scopes and bindings before a run", () => {
359
+ const record = readworkflow([
360
+ { id: "s1", kind: "readtext", label: "Read", target: "h1" },
361
+ {
362
+ id: "s2",
363
+ kind: "wait",
364
+ label: "Wait",
365
+ value: "10",
366
+ expression: {
367
+ left: { ref: "title" },
368
+ right: { literal: "!" },
369
+ operator: "concat",
370
+ result: "banner",
371
+ resultkind: "string",
372
+ },
373
+ },
374
+ ]);
375
+ expect(validateworkflow(record, { inputs: ["title"] })).toEqual({ allowed: true });
376
+ expect(validateworkflow(record, { kindallowed: (kind: string) => kind !== "wait" })).toMatchObject({
377
+ allowed: false,
378
+ reason: expect.stringContaining("not a reviewed action kind"),
379
+ });
380
+ expect(validateworkflow(record)).toMatchObject({
381
+ allowed: false,
382
+ reason: expect.stringContaining("undefined variable title"),
383
+ });
384
+ const chained = readworkflow([
385
+ {
386
+ id: "s1",
387
+ kind: "readtext",
388
+ label: "Read",
389
+ target: "h1",
390
+ bindings: [{ variable: "title", kind: "string", stepid: "s2" }],
391
+ },
392
+ {
393
+ id: "s2",
394
+ kind: "readtext",
395
+ label: "Read more",
396
+ target: "p",
397
+ bindings: [{ variable: "title", kind: "string", stepid: "s1" }],
398
+ },
399
+ ]);
400
+ expect(validateworkflow(chained)).toMatchObject({
401
+ allowed: false,
402
+ reason: expect.stringContaining("earlier step"),
403
+ });
404
+ expect(() =>
405
+ readworkflow([
406
+ {
407
+ id: "s1",
408
+ kind: "wait",
409
+ label: "Wait",
410
+ value: "5",
411
+ bindings: [{ variable: "x", kind: "string", stepid: "ghost" }],
412
+ },
413
+ ]),
414
+ ).toThrow("unknown step ghost");
415
+ expect(validateworkflow(readworkflow())).toEqual({ allowed: true });
416
+ });
417
+ });
418
+
419
+ describe("variable scopes", () => {
420
+ it("resolves from the nearest scope outward and keeps shadowing local", () => {
421
+ const root: variablescope = {
422
+ name: "root",
423
+ variables: [{ name: "title", kind: "string", value: "outer", setat: now }],
424
+ };
425
+ const child = pushscope([root], "block", "root");
426
+ expect(child).toHaveLength(2);
427
+ expect(resolvevariable(child, "title")?.value).toBe("outer");
428
+ const shadowed: variablescope[] = [
429
+ root,
430
+ { name: "block", variables: [{ name: "title", kind: "string", value: "inner", setat: now }], parent: "root" },
431
+ ];
432
+ expect(resolvevariable(shadowed, "title")?.value).toBe("inner");
433
+ expect(resolvevariable(shadowed, "missing")).toBeUndefined();
434
+ const popped = popscope(shadowed);
435
+ expect(popped).toHaveLength(1);
436
+ expect(popscope([])).toHaveLength(0);
437
+ });
438
+
439
+ it("binds step outcomes into the newest scope and refuses absent paths", () => {
440
+ const outputs: Record<string, stepoutcome> = {
441
+ s0: { stepid: "s0", ok: true, summary: "42", details: { text: "hello", count: 3, tags: ["a", "b"] }, at: now },
442
+ };
443
+ const bound = bindvariables(
444
+ [{ name: "root", variables: [] }],
445
+ [
446
+ { variable: "title", kind: "string", stepid: "s0", path: "text" },
447
+ { variable: "count", kind: "number", stepid: "s0", path: "count" },
448
+ { variable: "tags", kind: "list", stepid: "s0", path: "tags" },
449
+ { variable: "summary", kind: "string", stepid: "s0" },
450
+ ],
451
+ outputs,
452
+ now,
453
+ );
454
+ expect(bound.produced).toEqual(["title", "count", "tags", "summary"]);
455
+ expect(resolvevariable(bound.scopes, "count")?.value).toBe(3);
456
+ expect(resolvevariable(bound.scopes, "tags")?.value).toEqual(["a", "b"]);
457
+ expect(resolvevariable(bound.scopes, "summary")?.value).toBe("42");
458
+ expect(() =>
459
+ bindvariables(
460
+ [{ name: "root", variables: [] }],
461
+ [{ variable: "x", kind: "string", stepid: "s0", path: "missing" }],
462
+ outputs,
463
+ now,
464
+ ),
465
+ ).toThrow("found no value");
466
+ expect(() =>
467
+ bindvariables(
468
+ [{ name: "root", variables: [] }],
469
+ [{ variable: "n", kind: "number", stepid: "s0", path: "text" }],
470
+ outputs,
471
+ now,
472
+ ),
473
+ ).toThrow("not a finite number");
474
+ expect(() =>
475
+ bindvariables(
476
+ [{ name: "root", variables: [] }],
477
+ [{ variable: "b", kind: "boolean", stepid: "s0", path: "text" }],
478
+ outputs,
479
+ now,
480
+ ),
481
+ ).toThrow("not a boolean");
482
+ });
483
+ });
484
+
485
+ describe("expressioneval", () => {
486
+ const scopes = (values: Record<string, string | number | boolean | string[]>): variablescope[] => [
487
+ {
488
+ name: "root",
489
+ variables: Object.entries(values).map(([name, value]) => ({
490
+ name,
491
+ kind: Array.isArray(value)
492
+ ? "list"
493
+ : typeof value === "number"
494
+ ? "number"
495
+ : typeof value === "boolean"
496
+ ? "boolean"
497
+ : "string",
498
+ value,
499
+ setat: now,
500
+ })),
501
+ },
502
+ ];
503
+
504
+ it("evaluates arithmetic, comparison and logic operators", () => {
505
+ expect(
506
+ expressioneval(
507
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "add", result: "sum", resultkind: "number" },
508
+ scopes({ a: 2, b: 3 }),
509
+ ),
510
+ ).toBe(5);
511
+ expect(
512
+ expressioneval(
513
+ { left: { literal: 10 }, right: { literal: 4 }, operator: "subtract", result: "d", resultkind: "number" },
514
+ [],
515
+ ),
516
+ ).toBe(6);
517
+ expect(
518
+ expressioneval(
519
+ { left: { literal: 3 }, right: { literal: 4 }, operator: "multiply", result: "p", resultkind: "number" },
520
+ [],
521
+ ),
522
+ ).toBe(12);
523
+ expect(
524
+ expressioneval(
525
+ { left: { literal: 10 }, right: { literal: 4 }, operator: "divide", result: "q", resultkind: "number" },
526
+ [],
527
+ ),
528
+ ).toBe(2.5);
529
+ expect(
530
+ expressioneval(
531
+ { left: { literal: 10 }, right: { literal: 3 }, operator: "modulo", result: "r", resultkind: "number" },
532
+ [],
533
+ ),
534
+ ).toBe(1);
535
+ expect(
536
+ expressioneval(
537
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "less", result: "lt", resultkind: "boolean" },
538
+ scopes({ a: 1, b: 2 }),
539
+ ),
540
+ ).toBe(true);
541
+ expect(
542
+ expressioneval(
543
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "greaterequal", result: "ge", resultkind: "boolean" },
544
+ scopes({ a: 3, b: 2 }),
545
+ ),
546
+ ).toBe(true);
547
+ expect(
548
+ expressioneval(
549
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "equal", result: "eq", resultkind: "boolean" },
550
+ scopes({ a: "x", b: "x" }),
551
+ ),
552
+ ).toBe(true);
553
+ expect(
554
+ expressioneval(
555
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "notequal", result: "ne", resultkind: "boolean" },
556
+ scopes({ a: 1, b: 2 }),
557
+ ),
558
+ ).toBe(true);
559
+ expect(
560
+ expressioneval(
561
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "and", result: "both", resultkind: "boolean" },
562
+ scopes({ a: true, b: false }),
563
+ ),
564
+ ).toBe(false);
565
+ expect(
566
+ expressioneval(
567
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "or", result: "any", resultkind: "boolean" },
568
+ scopes({ a: true, b: false }),
569
+ ),
570
+ ).toBe(true);
571
+ expect(
572
+ expressioneval(
573
+ { left: { ref: "a" }, operator: "not", result: "flipped", resultkind: "boolean" },
574
+ scopes({ a: true }),
575
+ ),
576
+ ).toBe(false);
577
+ });
578
+
579
+ it("coerces operand kinds and rejects mismatched operators", () => {
580
+ expect(
581
+ expressioneval(
582
+ { left: { ref: "a" }, right: { literal: 2 }, operator: "add", result: "sum", resultkind: "number" },
583
+ scopes({ a: "3" }),
584
+ ),
585
+ ).toBe(5);
586
+ expect(() =>
587
+ expressioneval(
588
+ { left: { ref: "a" }, right: { literal: 1 }, operator: "add", result: "sum", resultkind: "number" },
589
+ scopes({ a: "abc" }),
590
+ ),
591
+ ).toThrow("not a number");
592
+ expect(() =>
593
+ expressioneval(
594
+ { left: { literal: 1 }, right: { literal: 0 }, operator: "divide", result: "q", resultkind: "number" },
595
+ [],
596
+ ),
597
+ ).toThrow("divides by zero");
598
+ expect(() =>
599
+ expressioneval(
600
+ { left: { literal: 1 }, right: { literal: 0 }, operator: "modulo", result: "r", resultkind: "number" },
601
+ [],
602
+ ),
603
+ ).toThrow("divides by zero");
604
+ expect(() =>
605
+ expressioneval(
606
+ { left: { ref: "a" }, right: { literal: true }, operator: "and", result: "b", resultkind: "boolean" },
607
+ scopes({ a: "yes" }),
608
+ ),
609
+ ).toThrow("not a boolean");
610
+ expect(() =>
611
+ expressioneval({ left: { ref: "ghost" }, operator: "not", result: "b", resultkind: "boolean" }, []),
612
+ ).toThrow("undefined variable ghost");
613
+ expect(() =>
614
+ expressioneval(
615
+ { left: { ref: "items" }, right: { literal: 1 }, operator: "add", result: "n", resultkind: "number" },
616
+ scopes({ items: ["a"] }),
617
+ ),
618
+ ).toThrow("list");
619
+ });
620
+
621
+ it("evaluates text, contains and length operators including lists", () => {
622
+ expect(
623
+ expressioneval(
624
+ { left: { ref: "a" }, right: { ref: "b" }, operator: "concat", result: "joined", resultkind: "string" },
625
+ scopes({ a: "or", b: "der" }),
626
+ ),
627
+ ).toBe("order");
628
+ expect(
629
+ expressioneval(
630
+ { left: { ref: "items" }, right: { literal: "b" }, operator: "contains", result: "has", resultkind: "boolean" },
631
+ scopes({ items: ["a", "b"] }),
632
+ ),
633
+ ).toBe(true);
634
+ expect(
635
+ expressioneval(
636
+ {
637
+ left: { ref: "text" },
638
+ right: { literal: "ell" },
639
+ operator: "contains",
640
+ result: "has",
641
+ resultkind: "boolean",
642
+ },
643
+ scopes({ text: "hello" }),
644
+ ),
645
+ ).toBe(true);
646
+ expect(
647
+ expressioneval(
648
+ { left: { ref: "items" }, operator: "length", result: "count", resultkind: "number" },
649
+ scopes({ items: ["a", "b", "c"] }),
650
+ ),
651
+ ).toBe(3);
652
+ expect(
653
+ expressioneval(
654
+ { left: { ref: "text" }, operator: "length", result: "count", resultkind: "number" },
655
+ scopes({ text: "hello" }),
656
+ ),
657
+ ).toBe(5);
658
+ });
659
+ });
660
+
661
+ describe("regexextract", () => {
662
+ it("stores named captures as string variables and reports no match honestly", () => {
663
+ const rule = regexruleof({ pattern: "order-(?<code>[a-z]+)-(?<num>\\d+)", flags: "", groups: ["code", "num"] })!;
664
+ const matched = regexextract(rule, "ref order-abc-42 end", now);
665
+ expect(matched.matched).toBe(true);
666
+ expect(matched.variables.map((variable) => [variable.name, variable.value])).toEqual([
667
+ ["code", "abc"],
668
+ ["num", "42"],
669
+ ]);
670
+ const missed = regexextract(rule, "no marker here", now);
671
+ expect(missed).toEqual({ matched: false, variables: [] });
672
+ const optional = regexruleof({
673
+ pattern: "ref-(?<code>[a-z]+)(?:-(?<num>\\d+))?",
674
+ flags: "",
675
+ groups: ["code", "num"],
676
+ })!;
677
+ const absent = regexextract(optional, "ref-abc", now);
678
+ expect(absent.matched).toBe(true);
679
+ expect(absent.variables.map((variable) => [variable.name, variable.value])).toEqual([
680
+ ["code", "abc"],
681
+ ["num", ""],
682
+ ]);
683
+ });
684
+ });
685
+
686
+ describe("delayjitter and waitelementplan", () => {
687
+ it("samples the delay inside the reviewed window across seeded runs", () => {
688
+ const delay = { base: 1000, jitter: 400 };
689
+ const samples = Array.from({ length: 200 }, (_, index) => delayjitter(delay, index + 1));
690
+ for (const sample of samples) {
691
+ expect(sample).toBeGreaterThanOrEqual(800);
692
+ expect(sample).toBeLessThanOrEqual(1200);
693
+ }
694
+ expect(new Set(samples.map((sample) => Math.round(sample))).size).toBeGreaterThan(50);
695
+ expect(delayjitter({ base: 500, jitter: 0 }, 7)).toBe(500);
696
+ expect(delayjitter({ base: 10, jitter: 100 }, 3)).toBeGreaterThanOrEqual(0);
697
+ expect(delayjitter({ base: 0, jitter: 0 }, 3)).toBe(0);
698
+ });
699
+
700
+ it("plans the element wait polling inside the reviewed timeout and poll interval", () => {
701
+ expect(waitelementplan({ timeout: 1000, poll: 250 })).toEqual({ probes: 5, lastwait: 0 });
702
+ expect(waitelementplan({ timeout: 1100, poll: 250 })).toEqual({ probes: 5, lastwait: 100 });
703
+ expect(waitelementplan({ timeout: 0, poll: 250 })).toEqual({ probes: 1, lastwait: 0 });
704
+ expect(waitelementplan({ timeout: 1000, poll: 0 })).toEqual({ probes: 1, lastwait: 0 });
705
+ });
706
+ });
707
+
708
+ describe("run lifecycle", () => {
709
+ it("pauses, resumes and cancels with recorded reasons", () => {
710
+ const run = newworkflowrun({ id: "run1", workflowid: "wf1", now });
711
+ expect(run.state).toBe("pending");
712
+ expect(run.cursor).toBe(0);
713
+ const dry = newworkflowrun({ id: "run2", workflowid: "wf1", dryrun: true, now });
714
+ expect(dry.dryrun).toBe(true);
715
+ const running: workflowrun = { ...run, state: "running" };
716
+ const paused = pauserun(running, now + 10);
717
+ expect(paused.state).toBe("paused");
718
+ expect(paused.cursor).toBe(0);
719
+ expect(() => pauserun(paused, now + 20)).toThrow("Only a running");
720
+ const cancelled = cancelrun(running, "user cancel", now + 30);
721
+ expect(cancelled.state).toBe("cancelled");
722
+ expect(cancelled.cancelreason).toBe("user cancel");
723
+ expect(cancelrun({ ...run, state: "done" }, "late", now)).toEqual({ ...run, state: "done" });
724
+ });
725
+ });
726
+
727
+ describe("runstep", () => {
728
+ it("executes exactly one step with interpolation, expression and extract support", async () => {
729
+ const step = workflowstepof({
730
+ id: "s1",
731
+ kind: "readtext",
732
+ label: "Read the heading",
733
+ target: 'h1[data-name="${name}"]',
734
+ expression: {
735
+ left: { ref: "name" },
736
+ right: { literal: "!" },
737
+ operator: "concat",
738
+ result: "greeting",
739
+ resultkind: "string",
740
+ },
741
+ })!;
742
+ const executed = await runstep({
743
+ step,
744
+ scopes: [{ name: "root", variables: [{ name: "name", kind: "string", value: "dev", setat: now }] }],
745
+ outputs: {},
746
+ execute: async (dispatched) => ({ ok: true, summary: `read ${dispatched.target}`, details: { text: "hello" } }),
747
+ now,
748
+ });
749
+ expect(executed.output.ok).toBe(true);
750
+ expect(executed.output.summary).toBe('read h1[data-name="dev"]');
751
+ expect(executed.log.state).toBe("done");
752
+ expect(executed.log.produced).toEqual(["greeting"]);
753
+ expect(executed.log.consumed).toEqual(["name"]);
754
+ expect(resolvevariable(executed.scopes, "greeting")?.value).toBe("dev!");
755
+ });
756
+
757
+ it("refuses undefined variable references inside a single step", async () => {
758
+ const step = workflowstepof({ id: "s1", kind: "readtext", label: "Read", target: "h1", value: "${missing}" })!;
759
+ const executed = await runstep({
760
+ step,
761
+ scopes: [{ name: "root", variables: [] }],
762
+ outputs: {},
763
+ execute: async () => ({ ok: true, summary: "never" }),
764
+ now,
765
+ });
766
+ expect(executed.output.ok).toBe(false);
767
+ expect(executed.output.summary).toContain("undefined variable missing");
768
+ expect(executed.log.state).toBe("failed");
769
+ });
770
+
771
+ it("runs one chosen step in isolation outside the run loop", async () => {
772
+ const step = workflowstepof({ id: "s9", kind: "readtext", label: "Isolated read", target: "p" })!;
773
+ const executed = await runstep({
774
+ step,
775
+ scopes: [{ name: "root", variables: [] }],
776
+ outputs: {},
777
+ execute: async (dispatched) => ({ ok: true, summary: `isolated ${dispatched.kind}` }),
778
+ now,
779
+ });
780
+ expect(executed.output.summary).toBe("isolated readtext");
781
+ expect(executed.log.checkpoint).toBe(true);
782
+ });
783
+ });
784
+
785
+ describe("runworkflow", () => {
786
+ const record = readworkflow();
787
+
788
+ it("advances one step at a time with checkpoints after every completed step", async () => {
789
+ const checkpoints: number[] = [];
790
+ const result = await runworkflow({
791
+ record,
792
+ run: newworkflowrun({ id: "run1", workflowid: record.id, now }),
793
+ execute: async (step) => ({ ok: true, summary: `did ${step.label}` }),
794
+ now,
795
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
796
+ oncheckpoint: async (state) => {
797
+ checkpoints.push(state.run.cursor);
798
+ },
799
+ });
800
+ expect(result.run.state).toBe("done");
801
+ expect(result.run.cursor).toBe(2);
802
+ expect(checkpoints).toEqual([1, 2]);
803
+ expect(result.log.map((entry) => entry.state)).toEqual(["done", "done"]);
804
+ expect(result.log.every((entry) => entry.checkpoint === true)).toBe(true);
805
+ });
806
+
807
+ it("opens a child scope per block region and closes it at the end", async () => {
808
+ const inner = workflowblockof({
809
+ name: "inner",
810
+ label: "Inner",
811
+ steps: [{ id: "i1", kind: "wait", label: "Inner wait", value: "5" }],
812
+ })!;
813
+ const blocked = composeworkflow({
814
+ name: "blocked",
815
+ version: 1,
816
+ origins: ["https://example.com"],
817
+ steps: [
818
+ { block: "inner", label: "Call inner" },
819
+ { id: "t1", kind: "readtext", label: "Top read", target: "p" },
820
+ ],
821
+ blocks: [inner],
822
+ now,
823
+ kindallowed,
824
+ riskof,
825
+ });
826
+ const result = await runworkflow({
827
+ record: blocked,
828
+ run: newworkflowrun({ workflowid: blocked.id, now }),
829
+ execute: async () => ({ ok: true, summary: "ok" }),
830
+ now,
831
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
832
+ });
833
+ expect(result.log[0]?.block).toBe("inner");
834
+ expect(result.log[1]?.block).toBeUndefined();
835
+ expect(result.scopes).toHaveLength(1);
836
+ });
837
+
838
+ it("refuses to run outside an approved session and outside the origin grants", async () => {
839
+ const run = newworkflowrun({ workflowid: record.id, now });
840
+ await expect(
841
+ runworkflow({
842
+ record,
843
+ run,
844
+ execute: async () => ({ ok: true, summary: "x" }),
845
+ now,
846
+ gates: { sessionactive: false, planapproved: true, origingranted: () => true },
847
+ }),
848
+ ).rejects.toThrow("approved session");
849
+ await expect(
850
+ runworkflow({
851
+ record,
852
+ run,
853
+ execute: async () => ({ ok: true, summary: "x" }),
854
+ now,
855
+ gates: { sessionactive: true, planapproved: false, origingranted: () => true },
856
+ }),
857
+ ).rejects.toThrow("approved plan");
858
+ await expect(
859
+ runworkflow({
860
+ record,
861
+ run,
862
+ execute: async () => ({ ok: true, summary: "x" }),
863
+ now,
864
+ gates: { sessionactive: true, planapproved: true, origingranted: () => false },
865
+ }),
866
+ ).rejects.toThrow("outside the session grants");
867
+ await expect(
868
+ runworkflow({ record, run: { ...run, state: "done" }, execute: async () => ({ ok: true, summary: "x" }), now }),
869
+ ).rejects.toThrow("already done");
870
+ });
871
+
872
+ it("fails the run when a step fails and resumes a paused run from its checkpoint", async () => {
873
+ let calls = 0;
874
+ const failing = await runworkflow({
875
+ record,
876
+ run: newworkflowrun({ workflowid: record.id, now }),
877
+ execute: async (step) => {
878
+ calls += 1;
879
+ return step.id === "s1" ? { ok: true, summary: "first done" } : { ok: false, summary: "the page refused" };
880
+ },
881
+ now,
882
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
883
+ });
884
+ expect(failing.run.state).toBe("failed");
885
+ expect(failing.run.cursor).toBe(1);
886
+ expect(failing.run.failreason).toBe("the page refused");
887
+ const resumed = await runworkflow({
888
+ record,
889
+ run: { ...failing.run, state: "paused" },
890
+ log: failing.log,
891
+ outputs: failing.outputs,
892
+ execute: async () => {
893
+ calls += 1;
894
+ return { ok: true, summary: "recovered" };
895
+ },
896
+ now,
897
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
898
+ });
899
+ expect(resumed.run.state).toBe("done");
900
+ expect(resumed.run.cursor).toBe(2);
901
+ expect(resumed.log.map((entry) => entry.stepid)).toEqual(["s1", "s2", "s2"]);
902
+ expect(resumed.log[1]?.state).toBe("failed");
903
+ expect(resumed.log[2]?.state).toBe("done");
904
+ expect(calls).toBe(3);
905
+ });
906
+ });
907
+
908
+ describe("dryrunworkflow", () => {
909
+ it("evaluates every step read only with no mutation and refuses steps without a projection", () => {
910
+ const record = composeworkflow({
911
+ name: "mixed",
912
+ version: 1,
913
+ origins: ["https://example.com"],
914
+ steps: [
915
+ { id: "r1", kind: "readtext", label: "Read only", target: "p" },
916
+ { id: "c1", kind: "click", label: "Mutating click", target: "#submit" },
917
+ {
918
+ id: "d1",
919
+ kind: "delay",
920
+ label: "Jittered delay",
921
+ options: JSON.stringify({ delay: { base: 100, jitter: 20 } }),
922
+ },
923
+ ] as never,
924
+ now,
925
+ kindallowed,
926
+ riskof,
927
+ });
928
+ const evaluated = dryrunworkflow({
929
+ record,
930
+ run: newworkflowrun({ workflowid: record.id, dryrun: true, now }),
931
+ now,
932
+ projection: dryrunprojection,
933
+ });
934
+ expect(evaluated.run.state).toBe("done");
935
+ expect(evaluated.run.dryrun).toBe(true);
936
+ expect(evaluated.run.cursor).toBe(3);
937
+ expect(evaluated.log.map((entry) => entry.state)).toEqual(["done", "refused", "done"]);
938
+ expect(evaluated.log[1]?.summary).toContain("no read only projection");
939
+ expect(evaluated.log[2]?.summary).toContain("jitter window");
940
+ expect(evaluated.log.every((entry) => entry.duration === 0)).toBe(true);
941
+ expect(dryrunprojection({ id: "x", kind: "click", label: "Click", target: "#a" })).toBeUndefined();
942
+ expect(dryrunprojection({ id: "x", kind: "waitelement", label: "Wait", target: ".item" })).toContain("poll");
943
+ expect(dryrunprojection({ id: "x", kind: "compute", label: "Compute" })).toContain("expression");
944
+ expect(dryrunprojection({ id: "x", kind: "extractvars", label: "Extract" })).toContain("regex");
945
+ });
946
+ });
947
+
948
+ describe("workflow policy", () => {
949
+ const step = (kind: toolstep["kind"], options: Record<string, unknown>): toolstep => ({
950
+ id: "w1",
951
+ kind,
952
+ summary: "Reviewed workflow step",
953
+ risk: actionrisk(kind),
954
+ options: JSON.stringify(options),
955
+ });
956
+ const workflowpayload = (overides: Record<string, unknown> = {}): Record<string, unknown> => ({
957
+ name: "reader",
958
+ version: 1,
959
+ origins: ["https://example.com"],
960
+ steps: [{ id: "s1", kind: "readtext", label: "Read", target: "h1" }],
961
+ ...overides,
962
+ });
963
+
964
+ it("validates the workflow grammar of every kind before any run is proposed", () => {
965
+ expect(validatestep(step("composeworkflow", { workflow: workflowpayload() }), "https://example.com")).toEqual({
966
+ allowed: true,
967
+ });
968
+ expect(
969
+ validatestep(step("composeworkflow", { workflow: workflowpayload({ name: " " }) }), "https://example.com"),
970
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("name") });
971
+ expect(
972
+ validatestep(step("composeworkflow", { workflow: workflowpayload({ version: 0 }) }), "https://example.com"),
973
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("version") });
974
+ expect(
975
+ validatestep(step("composeworkflow", { workflow: workflowpayload({ origins: [] }) }), "https://example.com"),
976
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("origin") });
977
+ expect(
978
+ validatestep(
979
+ step("composeworkflow", { workflow: workflowpayload({ origins: ["http://example.com"] }) }),
980
+ "https://example.com",
981
+ ),
982
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("HTTPS") });
983
+ expect(
984
+ validatestep(step("composeworkflow", { workflow: workflowpayload({ steps: [] }) }), "https://example.com"),
985
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("step list") });
986
+ expect(
987
+ validatestep(
988
+ step("composeworkflow", {
989
+ workflow: workflowpayload({ steps: [{ id: "s1", kind: "explode", label: "Ghost kind" }] }),
990
+ }),
991
+ "https://example.com",
992
+ ),
993
+ ).toMatchObject({ allowed: false });
994
+ expect(
995
+ validatestep(
996
+ step("composeworkflow", {
997
+ workflow: workflowpayload({
998
+ steps: [
999
+ { id: "s1", kind: "wait", label: "Wait", bindings: [{ variable: "v", kind: "string", stepid: "ghost" }] },
1000
+ ],
1001
+ }),
1002
+ }),
1003
+ "https://example.com",
1004
+ ),
1005
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("ghost") });
1006
+ expect(validatestep(step("composeworkflow", {}), "https://example.com")).toMatchObject({
1007
+ allowed: false,
1008
+ reason: expect.stringContaining("workflow payload"),
1009
+ });
1010
+ expect(
1011
+ validatestep(
1012
+ step("savetemplate", {
1013
+ template: { name: "shared", step: { id: "s1", kind: "click", label: "Click", target: "#x" } },
1014
+ }),
1015
+ "https://example.com",
1016
+ ),
1017
+ ).toEqual({ allowed: true });
1018
+ expect(validatestep(step("savetemplate", { template: { name: " " } }), "https://example.com")).toMatchObject({
1019
+ allowed: false,
1020
+ });
1021
+ expect(validatestep(step("runworkflow", { workflowid: "wf1", reviewed: true }), "https://example.com")).toEqual({
1022
+ allowed: true,
1023
+ });
1024
+ expect(validatestep(step("runworkflow", { workflowid: "wf1" }), "https://example.com")).toMatchObject({
1025
+ allowed: false,
1026
+ reason: expect.stringContaining("run review"),
1027
+ });
1028
+ expect(validatestep(step("runworkflow", { reviewed: true }), "https://example.com")).toMatchObject({
1029
+ allowed: false,
1030
+ });
1031
+ expect(
1032
+ validatestep(
1033
+ step("runworkflow", { workflowid: "wf1", reviewed: true, variables: { count: "many" } }),
1034
+ "https://example.com",
1035
+ ),
1036
+ ).toEqual({ allowed: true });
1037
+ expect(
1038
+ validatestep(
1039
+ step("runworkflow", { workflowid: "wf1", reviewed: true, variables: { count: { nested: true } } }),
1040
+ "https://example.com",
1041
+ ),
1042
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("variables") });
1043
+ expect(validatestep(step("dryrun", { workflowid: "wf1" }), "https://example.com")).toEqual({ allowed: true });
1044
+ expect(validatestep(step("dryrun", {}), "https://example.com")).toMatchObject({ allowed: false });
1045
+ expect(validatestep(step("delay", { delay: { base: 100, jitter: 20 } }), "https://example.com")).toEqual({
1046
+ allowed: true,
1047
+ });
1048
+ expect(validatestep(step("delay", { delay: { base: -1, jitter: 0 } }), "https://example.com")).toMatchObject({
1049
+ allowed: false,
1050
+ reason: expect.stringContaining("base"),
1051
+ });
1052
+ expect(validatestep(step("delay", { delay: { base: 100, jitter: -5 } }), "https://example.com")).toMatchObject({
1053
+ allowed: false,
1054
+ reason: expect.stringContaining("jitter"),
1055
+ });
1056
+ expect(validatestep(step("delay", {}), "https://example.com")).toMatchObject({ allowed: false });
1057
+ expect(
1058
+ validatestep(
1059
+ step("waitelement", { wait: { selector: ".item", timeout: 5000, poll: 250 } }),
1060
+ "https://example.com",
1061
+ ),
1062
+ ).toEqual({ allowed: true });
1063
+ expect(
1064
+ validatestep(step("waitelement", { wait: { selector: " ", timeout: 5, poll: 1 } }), "https://example.com"),
1065
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("selector") });
1066
+ expect(
1067
+ validatestep(step("waitelement", { wait: { selector: ".item", timeout: -1, poll: 1 } }), "https://example.com"),
1068
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("timeout") });
1069
+ expect(
1070
+ validatestep(step("waitelement", { wait: { selector: ".item", timeout: 5, poll: -1 } }), "https://example.com"),
1071
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("poll") });
1072
+ expect(
1073
+ validatestep(
1074
+ step("compute", {
1075
+ expression: {
1076
+ left: { ref: "a" },
1077
+ right: { literal: 1 },
1078
+ operator: "add",
1079
+ result: "total",
1080
+ resultkind: "number",
1081
+ },
1082
+ }),
1083
+ "https://example.com",
1084
+ ),
1085
+ ).toEqual({ allowed: true });
1086
+ expect(
1087
+ validatestep(
1088
+ step("compute", {
1089
+ expression: { left: { ref: "a" }, operator: "explode", result: "total", resultkind: "number" },
1090
+ }),
1091
+ "https://example.com",
1092
+ ),
1093
+ ).toMatchObject({ allowed: false });
1094
+ expect(
1095
+ validatestep(
1096
+ step("compute", {
1097
+ expression: {
1098
+ left: { literal: true },
1099
+ right: { literal: 1 },
1100
+ operator: "add",
1101
+ result: "total",
1102
+ resultkind: "number",
1103
+ },
1104
+ }),
1105
+ "https://example.com",
1106
+ ),
1107
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("numeric operands") });
1108
+ expect(
1109
+ validatestep(
1110
+ step("compute", {
1111
+ expression: {
1112
+ left: { literal: 1 },
1113
+ right: { literal: 2 },
1114
+ operator: "and",
1115
+ result: "both",
1116
+ resultkind: "boolean",
1117
+ },
1118
+ }),
1119
+ "https://example.com",
1120
+ ),
1121
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("boolean operands") });
1122
+ expect(
1123
+ validatestep(
1124
+ step("compute", {
1125
+ expression: {
1126
+ left: { ref: "a" },
1127
+ right: { literal: 2 },
1128
+ operator: "add",
1129
+ result: "total",
1130
+ resultkind: "boolean",
1131
+ },
1132
+ }),
1133
+ "https://example.com",
1134
+ ),
1135
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("number result kind") });
1136
+ expect(
1137
+ validatestep(
1138
+ step("compute", {
1139
+ expression: {
1140
+ left: { ref: "a" },
1141
+ operator: "not",
1142
+ right: { literal: true },
1143
+ result: "n",
1144
+ resultkind: "boolean",
1145
+ },
1146
+ }),
1147
+ "https://example.com",
1148
+ ),
1149
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("one operand") });
1150
+ expect(
1151
+ validatestep(
1152
+ step("compute", {
1153
+ expression: {
1154
+ left: { ref: "a" },
1155
+ operator: "length",
1156
+ right: { literal: 1 },
1157
+ result: "n",
1158
+ resultkind: "number",
1159
+ },
1160
+ }),
1161
+ "https://example.com",
1162
+ ),
1163
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("one operand") });
1164
+ expect(
1165
+ validatestep(
1166
+ step("extractvars", { rule: { pattern: "(?<code>[a-z]+)", flags: "", groups: ["code"] }, text: "order-abc" }),
1167
+ "https://example.com",
1168
+ ),
1169
+ ).toEqual({ allowed: true });
1170
+ expect(
1171
+ validatestep(
1172
+ step("extractvars", { rule: { pattern: "(a+)+$", flags: "", groups: [] }, text: "x" }),
1173
+ "https://example.com",
1174
+ ),
1175
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("backtracking") });
1176
+ expect(
1177
+ validatestep(
1178
+ step("extractvars", { rule: { pattern: "(", flags: "", groups: [] }, text: "x" }),
1179
+ "https://example.com",
1180
+ ),
1181
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("compile") });
1182
+ expect(
1183
+ validatestep(step("extractvars", { rule: { pattern: "x", flags: "", groups: [] } }), "https://example.com"),
1184
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("text") });
1185
+ });
1186
+
1187
+ it("rejects unbounded backtracking regex shapes while bounded repeats stay user choices", () => {
1188
+ /* the explosive shapes arrive assembled at runtime, because a constant explosive pattern would itself read as an unsafe regex reaching the compile check */
1189
+ const explosiveplus = ["(a", "+)+b"].join("");
1190
+ const explosivestar = ["(a", "*)*x"].join("");
1191
+ const explosiverepeat = ["(a{2,", "}){1,}"].join("");
1192
+ expect(validateregexrule(explosiveplus)).toMatchObject({ allowed: false });
1193
+ expect(validateregexrule(explosivestar)).toMatchObject({ allowed: false });
1194
+ expect(validateregexrule(explosiverepeat)).toMatchObject({ allowed: false });
1195
+ expect(validateregexrule("(?<code>[a-z]+)-\\d+")).toEqual({ allowed: true });
1196
+ expect(validateregexrule("a{2,5}")).toEqual({ allowed: true });
1197
+ expect(validateregexrule("[")).toMatchObject({ allowed: false, reason: expect.stringContaining("compile") });
1198
+ });
1199
+
1200
+ it("gates every workflow behind the session, plan review and the explicit run review", () => {
1201
+ const runstep = step("runworkflow", { workflowid: "wf1", reviewed: true });
1202
+ expect(workflowgate({ session, plan, step: runstep, tabid: 4, origin: "https://example.com", now })).toEqual({
1203
+ allowed: true,
1204
+ });
1205
+ expect(
1206
+ workflowgate({ session: undefined, plan, step: runstep, tabid: 4, origin: "https://example.com", now }),
1207
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("session") });
1208
+ expect(
1209
+ workflowgate({
1210
+ session: { ...session, pausedat: now - 1 },
1211
+ plan,
1212
+ step: runstep,
1213
+ tabid: 4,
1214
+ origin: "https://example.com",
1215
+ now,
1216
+ }),
1217
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("paused") });
1218
+ expect(
1219
+ workflowgate({
1220
+ session,
1221
+ plan: { ...plan, state: "pending" },
1222
+ step: runstep,
1223
+ tabid: 4,
1224
+ origin: "https://example.com",
1225
+ now,
1226
+ }),
1227
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("approved plan review") });
1228
+ expect(
1229
+ workflowgate({
1230
+ session,
1231
+ plan,
1232
+ step: step("runworkflow", { workflowid: "wf1" }),
1233
+ tabid: 4,
1234
+ origin: "https://example.com",
1235
+ now,
1236
+ }),
1237
+ ).toMatchObject({ allowed: false, reason: expect.stringContaining("run review") });
1238
+ expect(
1239
+ workflowgate({
1240
+ session,
1241
+ plan,
1242
+ step: step("dryrun", { workflowid: "wf1" }),
1243
+ tabid: 4,
1244
+ origin: "https://example.com",
1245
+ now,
1246
+ }),
1247
+ ).toEqual({ allowed: true });
1248
+ expect(canexecute({ session, plan, step: runstep, tabid: 4, origin: "https://example.com", now })).toEqual({
1249
+ allowed: true,
1250
+ });
1251
+ expect(
1252
+ canexecute({
1253
+ session,
1254
+ plan: { ...plan, state: "pending" },
1255
+ step: runstep,
1256
+ tabid: 4,
1257
+ origin: "https://example.com",
1258
+ now,
1259
+ }),
1260
+ ).toMatchObject({ allowed: false });
1261
+ });
1262
+ });
1263
+
1264
+ describe("workflow protocol", () => {
1265
+ it("parses a workflow proposal with the expanded step list and refuses ungranted origins", () => {
1266
+ const proposal = parseworkflowproposal(
1267
+ {
1268
+ version: protocolversion,
1269
+ workflow: {
1270
+ name: "reader",
1271
+ version: 2,
1272
+ origins: ["https://example.com"],
1273
+ steps: [{ id: "s1", kind: "readtext", label: "Read", target: "h1" }],
1274
+ blocks: [
1275
+ { name: "waitblock", label: "Waits", steps: [{ id: "b1", kind: "wait", label: "Wait", value: "5" }] },
1276
+ ],
1277
+ },
1278
+ },
1279
+ "https://example.com",
1280
+ ["https://example.com"],
1281
+ true,
1282
+ );
1283
+ expect(proposal.version).toBe(protocolversion);
1284
+ expect(proposal.dryrun).toBe(true);
1285
+ expect(proposal.workflow.version).toBe(2);
1286
+ expect(proposal.workflow.steps).toHaveLength(1);
1287
+ expect(Object.isFrozen(proposal.workflow)).toBe(true);
1288
+ expect(() =>
1289
+ parseworkflowproposal(
1290
+ {
1291
+ version: "1.0.0",
1292
+ workflow: {
1293
+ name: "x",
1294
+ version: 1,
1295
+ origins: ["https://example.com"],
1296
+ steps: [{ id: "s1", kind: "wait", label: "Wait" }],
1297
+ },
1298
+ },
1299
+ "https://example.com",
1300
+ ),
1301
+ ).toThrow("version");
1302
+ expect(() =>
1303
+ parseworkflowproposal(
1304
+ {
1305
+ version: protocolversion,
1306
+ workflow: {
1307
+ name: "x",
1308
+ version: 1,
1309
+ origins: ["https://elsewhere.example"],
1310
+ steps: [{ id: "s1", kind: "wait", label: "Wait" }],
1311
+ },
1312
+ },
1313
+ "https://example.com",
1314
+ ),
1315
+ ).toThrow("outside the grants");
1316
+ expect(() =>
1317
+ parseworkflowproposal(
1318
+ {
1319
+ version: protocolversion,
1320
+ workflow: {
1321
+ name: "x",
1322
+ version: 0,
1323
+ origins: ["https://example.com"],
1324
+ steps: [{ id: "s1", kind: "wait", label: "Wait" }],
1325
+ },
1326
+ },
1327
+ "https://example.com",
1328
+ ),
1329
+ ).toThrow("version");
1330
+ expect(() =>
1331
+ parseworkflowproposal(
1332
+ {
1333
+ version: protocolversion,
1334
+ workflow: { name: "x", version: 1, origins: [], steps: [{ id: "s1", kind: "wait", label: "Wait" }] },
1335
+ },
1336
+ "https://example.com",
1337
+ ),
1338
+ ).toThrow("origin");
1339
+ expect(() =>
1340
+ parseworkflowproposal(
1341
+ { version: protocolversion, workflow: { name: "x", version: 1, origins: ["https://example.com"], steps: [] } },
1342
+ "https://example.com",
1343
+ ),
1344
+ ).toThrow("at least one step");
1345
+ expect(() =>
1346
+ parseworkflowproposal(
1347
+ {
1348
+ version: protocolversion,
1349
+ workflow: {
1350
+ name: "x",
1351
+ version: 1,
1352
+ origins: ["https://example.com"],
1353
+ steps: [{ id: "s1", kind: "explode", label: "Ghost" }],
1354
+ },
1355
+ },
1356
+ "https://example.com",
1357
+ ),
1358
+ ).toThrow("reviewed action kind");
1359
+ });
1360
+
1361
+ it("carries the workflow envelope through proposals and outcomes", () => {
1362
+ const proposal = parseproposal(
1363
+ {
1364
+ version: protocolversion,
1365
+ plan: {
1366
+ objective: "Compose and run",
1367
+ steps: [
1368
+ {
1369
+ kind: "composeworkflow",
1370
+ options: JSON.stringify({
1371
+ workflow: {
1372
+ name: "reader",
1373
+ version: 1,
1374
+ origins: ["https://example.com"],
1375
+ steps: [{ id: "s1", kind: "readtext", label: "Read", target: "h1" }],
1376
+ },
1377
+ }),
1378
+ summary: "Compose the reader workflow.",
1379
+ },
1380
+ {
1381
+ kind: "runworkflow",
1382
+ options: JSON.stringify({ workflowid: "wf1", reviewed: true }),
1383
+ summary: "Run the reviewed workflow.",
1384
+ },
1385
+ { kind: "dryrun", options: JSON.stringify({ workflowid: "wf1" }), summary: "Dry run the workflow." },
1386
+ ],
1387
+ },
1388
+ },
1389
+ "https://example.com",
1390
+ );
1391
+ expect(proposal.plan.steps.map((step) => step.kind)).toEqual(["composeworkflow", "runworkflow", "dryrun"]);
1392
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "sensitive", "read"]);
1393
+ expect(() =>
1394
+ parseproposal(
1395
+ {
1396
+ version: protocolversion,
1397
+ plan: {
1398
+ objective: "Unreviewed run",
1399
+ steps: [
1400
+ { kind: "runworkflow", options: JSON.stringify({ workflowid: "wf1" }), summary: "Run without review." },
1401
+ ],
1402
+ },
1403
+ },
1404
+ "https://example.com",
1405
+ ),
1406
+ ).toThrow("run review");
1407
+ expect(() =>
1408
+ parseproposal(
1409
+ {
1410
+ version: protocolversion,
1411
+ plan: {
1412
+ objective: "Ungranted origin",
1413
+ steps: [
1414
+ {
1415
+ kind: "composeworkflow",
1416
+ options: JSON.stringify({
1417
+ workflow: {
1418
+ name: "x",
1419
+ version: 1,
1420
+ origins: ["https://elsewhere.example"],
1421
+ steps: [{ id: "s1", kind: "wait", label: "Wait" }],
1422
+ },
1423
+ }),
1424
+ summary: "Compose outside grants.",
1425
+ },
1426
+ ],
1427
+ },
1428
+ },
1429
+ "https://example.com",
1430
+ ),
1431
+ ).toThrow("outside the grants");
1432
+ const run = newworkflowrun({ id: "run1", workflowid: "wf1", now });
1433
+ const entries: runlogentry[] = [
1434
+ {
1435
+ stepid: "s1",
1436
+ label: "Read",
1437
+ state: "done",
1438
+ startedat: now,
1439
+ duration: 12,
1440
+ summary: "read the heading",
1441
+ produced: ["title"],
1442
+ consumed: [],
1443
+ checkpoint: true,
1444
+ },
1445
+ {
1446
+ stepid: "s2",
1447
+ label: "Click",
1448
+ state: "failed",
1449
+ startedat: now + 20,
1450
+ duration: 3,
1451
+ summary: "refused",
1452
+ block: "formblock",
1453
+ },
1454
+ ];
1455
+ const outcome = workflowoutcome({ run, entries });
1456
+ expect(outcome.runid).toBe("run1");
1457
+ expect(outcome.state).toBe("pending");
1458
+ expect(outcome.dryrun).toBeUndefined();
1459
+ expect(outcome.steps).toHaveLength(2);
1460
+ expect(outcome.steps[0]?.produced).toEqual(["title"]);
1461
+ expect(outcome.steps[1]?.block).toBe("formblock");
1462
+ const single = workflowoutcome({ run, entries, stepid: "s1" });
1463
+ expect(single.steps).toHaveLength(1);
1464
+ expect(single.steps[0]?.stepid).toBe("s1");
1465
+ const dryoutcome = workflowoutcome({ run: { ...run, dryrun: true }, entries });
1466
+ expect(dryoutcome.dryrun).toBe(true);
1467
+ const report = workflowreport({ workflows: [], runs: [run], templates: [] });
1468
+ expect(report.log).toEqual([]);
1469
+ expect(report.scopes).toEqual([]);
1470
+ expect(report.provenance).toEqual([]);
1471
+ expect(report.version).toBe(protocolversion);
1472
+ });
1473
+ });
1474
+
1475
+ describe("workflow memory", () => {
1476
+ it("stores workflow records, runs, runlogs, scopes, provenance and templates", async () => {
1477
+ const store = new sessionmemory(new fakeadapter());
1478
+ const record = readworkflow();
1479
+ const older = { ...record, version: 1, steps: [{ ...(record.steps[0] as object), id: "old" }] } as typeof record;
1480
+ await store.addworkflowrecord(older);
1481
+ await store.addworkflowrecord(record);
1482
+ await store.addworkflowrecord({ ...record, version: 2 });
1483
+ const versions = await store.getworkflowrecordversions();
1484
+ expect(versions).toHaveLength(2);
1485
+ expect(await store.listworkflows()).toHaveLength(1);
1486
+ expect((await store.getworkflowrecord(record.id))?.version).toBe(2);
1487
+ expect(await store.getworkflowrecord("missing")).toBeUndefined();
1488
+ const run = newworkflowrun({ id: "run1", workflowid: record.id, now });
1489
+ await store.setworkflowrun(run);
1490
+ await store.setworkflowrun({ ...run, state: "done", endedat: now + 5 });
1491
+ const runs = await store.listworkflowruns();
1492
+ expect(runs).toHaveLength(1);
1493
+ expect(runs[0]?.state).toBe("done");
1494
+ await store.addrunlogentry(run.id, {
1495
+ stepid: "s1",
1496
+ label: "Read",
1497
+ state: "done",
1498
+ startedat: now,
1499
+ duration: 4,
1500
+ summary: "read",
1501
+ checkpoint: true,
1502
+ });
1503
+ await store.addrunlogentry(run.id, {
1504
+ stepid: "s2",
1505
+ label: "Wait",
1506
+ state: "done",
1507
+ startedat: now + 1,
1508
+ duration: 2,
1509
+ summary: "waited",
1510
+ });
1511
+ const stored = await store.getrun(run.id);
1512
+ expect(stored?.run.state).toBe("done");
1513
+ expect(stored?.log).toHaveLength(2);
1514
+ expect(await store.getrun("missing")).toBeUndefined();
1515
+ const scopes: variablescope[] = [
1516
+ { name: "root", variables: [{ name: "title", kind: "string", value: "hi", setat: now }] },
1517
+ ];
1518
+ await store.setrunscopes(run.id, scopes);
1519
+ expect(await store.getrunscopes(run.id)).toEqual(scopes);
1520
+ await store.addworkflowprovenance(run.id, { runid: run.id, kind: "expression", name: "total", value: 5, at: now });
1521
+ await store.addworkflowprovenance(run.id, { runid: run.id, kind: "regex", name: "code", value: "abc", at: now });
1522
+ expect((await store.getworkflowprovenance(run.id)).map((entry) => entry.kind)).toEqual(["expression", "regex"]);
1523
+ await store.addsteptemplate({
1524
+ id: "t1",
1525
+ name: "shared click",
1526
+ origin: "https://example.com",
1527
+ step: { id: "s1", kind: "click", label: "Click", target: "#x" },
1528
+ sharedat: now,
1529
+ });
1530
+ await store.addsteptemplate({
1531
+ id: "t2",
1532
+ name: "shared click",
1533
+ origin: "https://example.com",
1534
+ step: { id: "s2", kind: "click", label: "Click", target: "#y" },
1535
+ sharedat: now + 1,
1536
+ });
1537
+ expect(await store.getsteptemplates()).toHaveLength(1);
1538
+ });
1539
+
1540
+ it("applies the runlog retention window as a user choice with no ceiling", async () => {
1541
+ const store = new sessionmemory(new fakeadapter());
1542
+ await store.setsettings({ runlogretention: 2 });
1543
+ for (const index of [1, 2, 3, 4])
1544
+ await store.addrunlogentry("run1", {
1545
+ stepid: `s${index}`,
1546
+ label: `Step ${index}`,
1547
+ state: "done",
1548
+ startedat: now + index,
1549
+ duration: 1,
1550
+ summary: "done",
1551
+ });
1552
+ const log = await store.getrunlog("run1");
1553
+ expect(log.map((entry) => entry.stepid)).toEqual(["s3", "s4"]);
1554
+ const unlimited = new sessionmemory(new fakeadapter());
1555
+ for (const index of [1, 2, 3])
1556
+ await unlimited.addrunlogentry("run1", {
1557
+ stepid: `s${index}`,
1558
+ label: `Step ${index}`,
1559
+ state: "done",
1560
+ startedat: now + index,
1561
+ duration: 1,
1562
+ summary: "done",
1563
+ });
1564
+ expect(await unlimited.getrunlog("run1")).toHaveLength(3);
1565
+ });
1566
+ });
1567
+
1568
+ describe("workflow progress", () => {
1569
+ it("records workflow evidence and tracks completion as executed over total steps", () => {
1570
+ let progress = recordworkflow(
1571
+ undefined,
1572
+ "run",
1573
+ "w1",
1574
+ { family: "run", detail: "Ran the workflow reader", runid: "run1", executed: 2, total: 3 },
1575
+ now,
1576
+ );
1577
+ progress = recordworkflow(
1578
+ progress,
1579
+ "run",
1580
+ "w1",
1581
+ { family: "dryrun", detail: "Dry ran the workflow reader", runid: "run2", executed: 3, refused: 0, total: 3 },
1582
+ now + 1,
1583
+ );
1584
+ const evidence = workflowevidences(progress, "run", "w1");
1585
+ expect(evidence.map((entry) => entry.family)).toEqual(["run", "dryrun"]);
1586
+ expect(evidence[0]?.total).toBe(3);
1587
+ expect((progress.outcomes ?? [])[0]?.summary).toContain("2 executed steps");
1588
+ expect(workflowevidences(progress, "other", "w1")).toEqual([]);
1589
+ expect(workflowshare(2, 4)).toBe(0.5);
1590
+ expect(workflowshare(5, 4)).toBe(1);
1591
+ expect(workflowshare(0, 0)).toBe(0);
1592
+ });
1593
+ });
1594
+
1595
+ describe("workflow editor engine", () => {
1596
+ it("binds the nested params of a block invocation into the block scope and refuses wrong kind defaults", async () => {
1597
+ const inner = workflowblockof({
1598
+ name: "inner",
1599
+ label: "Inner block",
1600
+ steps: [{ id: "i1", kind: "delay", label: "Delay by param", options: '{"base":"${base}"}' }],
1601
+ });
1602
+ if (!inner) throw new Error("The inner block did not normalize.");
1603
+ const record = composeworkflow({
1604
+ id: "wfparam",
1605
+ name: "params",
1606
+ version: 1,
1607
+ origins: ["https://example.com"],
1608
+ steps: [{ block: "inner", label: "Call inner", params: [{ name: "base", kind: "string", default: "250" }] }],
1609
+ blocks: [inner],
1610
+ now,
1611
+ kindallowed,
1612
+ riskof,
1613
+ });
1614
+ expect(record.steps[0]?.params).toEqual([{ name: "base", kind: "string", default: "250" }]);
1615
+ const dispatched: Array<string | undefined> = [];
1616
+ const result = await runworkflow({
1617
+ record,
1618
+ run: newworkflowrun({ workflowid: record.id, now }),
1619
+ execute: async (step) => {
1620
+ dispatched.push(step.options);
1621
+ return { ok: true, summary: "delayed" };
1622
+ },
1623
+ now,
1624
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
1625
+ });
1626
+ expect(result.run.state).toBe("done");
1627
+ expect(dispatched).toEqual(['{"base":"250"}']);
1628
+ const bad = composeworkflow({
1629
+ id: "wfparam",
1630
+ name: "params",
1631
+ version: 1,
1632
+ origins: ["https://example.com"],
1633
+ steps: [
1634
+ { block: "inner", label: "Call inner", params: [{ name: "base", kind: "number", default: "not a number" }] },
1635
+ ],
1636
+ blocks: [inner],
1637
+ now,
1638
+ kindallowed,
1639
+ riskof,
1640
+ });
1641
+ const failed = await runworkflow({
1642
+ record: bad,
1643
+ run: newworkflowrun({ workflowid: bad.id, now }),
1644
+ execute: async () => ({ ok: true, summary: "never reached" }),
1645
+ now,
1646
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
1647
+ });
1648
+ expect(failed.run.state).toBe("failed");
1649
+ expect(failed.run.failreason).toContain("nested parameter");
1650
+ });
1651
+
1652
+ it("checkpoints a background run and restores it from the last checkpoint after a worker wake", async () => {
1653
+ const record = readworkflow();
1654
+ const stored = {
1655
+ run: { ...newworkflowrun({ id: "bgrun", workflowid: record.id, now }), background: true } as workflowrun,
1656
+ scopes: [{ name: "root", variables: [] }] as variablescope[],
1657
+ log: [] as runlogentry[],
1658
+ };
1659
+ const interrupted = await runworkflow({
1660
+ record,
1661
+ run: stored.run,
1662
+ scopes: stored.scopes,
1663
+ log: stored.log,
1664
+ execute: async (step) => {
1665
+ if (step.id === "s2") throw new Error("service worker restarted");
1666
+ return { ok: true, summary: `did ${step.id}` };
1667
+ },
1668
+ now,
1669
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
1670
+ oncheckpoint: async (state) => {
1671
+ stored.run = state.run;
1672
+ stored.scopes = state.scopes;
1673
+ stored.log = [...state.log];
1674
+ },
1675
+ });
1676
+ expect(interrupted.run.state).toBe("failed");
1677
+ expect(interrupted.run.failreason).toBe("service worker restarted");
1678
+ expect(stored.run.cursor).toBe(1);
1679
+ expect(stored.log.map((entry) => entry.stepid)).toEqual(["s1"]);
1680
+ const paused = { ...stored.run, state: "paused" as const, pausedat: now + 1, pausekind: "interrupt" as const };
1681
+ const restored = await runworkflow({
1682
+ record,
1683
+ run: paused,
1684
+ scopes: stored.scopes,
1685
+ log: stored.log,
1686
+ execute: async (step) => ({ ok: true, summary: `restored ${step.id}` }),
1687
+ now,
1688
+ gates: { sessionactive: true, planapproved: true, origingranted: () => true },
1689
+ });
1690
+ expect(restored.run.state).toBe("done");
1691
+ expect(restored.run.cursor).toBe(2);
1692
+ expect(restored.log.map((entry) => entry.stepid)).toEqual(["s1", "s2"]);
1693
+ expect(restored.log[1]?.summary).toBe("restored s2");
1694
+ });
1695
+
1696
+ it("detects stalled runs and recovers them by the configured action", () => {
1697
+ const config = { enabled: true, stallthreshold: 30_000, action: "retry" as const };
1698
+ const stalled = {
1699
+ ...newworkflowrun({ id: "stall", workflowid: "wf", now: now - 90_000 }),
1700
+ state: "running" as const,
1701
+ cursor: 2,
1702
+ };
1703
+ const verdicts = watchdogpass({
1704
+ runs: [stalled],
1705
+ lastcompletedat: { stall: now - 60_000 },
1706
+ liveexecutors: ["stall"],
1707
+ config,
1708
+ now,
1709
+ });
1710
+ expect(verdicts).toHaveLength(1);
1711
+ expect(verdicts[0]?.verdict).toBe("stalled");
1712
+ expect(verdicts[0]?.action).toBe("retry");
1713
+ expect(verdicts[0]?.reason).toContain("60");
1714
+ expect(verdicts[0]?.lastcompletedat).toBe(now - 60_000);
1715
+ const pausing = watchdogpass({
1716
+ runs: [stalled],
1717
+ lastcompletedat: { stall: now - 60_000 },
1718
+ liveexecutors: ["stall"],
1719
+ config: { enabled: true, stallthreshold: 30_000, action: "pause" },
1720
+ now,
1721
+ });
1722
+ expect(pausing[0]?.action).toBe("pause");
1723
+ const cancelling = watchdogpass({
1724
+ runs: [stalled],
1725
+ lastcompletedat: { stall: now - 60_000 },
1726
+ liveexecutors: ["stall"],
1727
+ config: { enabled: true, stallthreshold: 30_000, action: "cancel" },
1728
+ now,
1729
+ });
1730
+ expect(cancelling[0]?.action).toBe("cancel");
1731
+ const healthy = watchdogpass({
1732
+ runs: [stalled],
1733
+ lastcompletedat: { stall: now - 100 },
1734
+ liveexecutors: ["stall"],
1735
+ config,
1736
+ now,
1737
+ });
1738
+ expect(healthy[0]?.verdict).toBe("healthy");
1739
+ expect(healthy[0]?.action).toBe("none");
1740
+ expect(
1741
+ watchdogpass({
1742
+ runs: [{ ...stalled, state: "paused" }],
1743
+ lastcompletedat: { stall: now - 90_000 },
1744
+ liveexecutors: [],
1745
+ config,
1746
+ now,
1747
+ }),
1748
+ ).toEqual([]);
1749
+ });
1750
+
1751
+ it("reaps zombie runs that browser shutdowns left behind", () => {
1752
+ const config = { enabled: true, stallthreshold: 30_000, action: "pause" as const, zombiewindow: 120_000 };
1753
+ const zombie = {
1754
+ ...newworkflowrun({ id: "zomb", workflowid: "wf", now: now - 300_000 }),
1755
+ state: "running" as const,
1756
+ cursor: 1,
1757
+ };
1758
+ const verdicts = watchdogpass({
1759
+ runs: [zombie],
1760
+ lastcompletedat: { zomb: now - 200_000 },
1761
+ liveexecutors: [],
1762
+ config,
1763
+ now,
1764
+ });
1765
+ expect(verdicts[0]?.verdict).toBe("zombie");
1766
+ expect(verdicts[0]?.action).toBe("reap");
1767
+ expect(verdicts[0]?.reason).toContain("checkpoint 1");
1768
+ const freshinterrupt = watchdogpass({
1769
+ runs: [zombie],
1770
+ lastcompletedat: { zomb: now - 1_000 },
1771
+ liveexecutors: [],
1772
+ config,
1773
+ now,
1774
+ });
1775
+ expect(freshinterrupt).toEqual([]);
1776
+ const nowindow = watchdogpass({
1777
+ runs: [zombie],
1778
+ lastcompletedat: { zomb: now - 200_000 },
1779
+ liveexecutors: [],
1780
+ config: { enabled: true, stallthreshold: 30_000, action: "pause" },
1781
+ now,
1782
+ });
1783
+ expect(nowindow).toEqual([]);
1784
+ });
1785
+ });