@oomol-lab/open-flow 0.1.0-beta.72 → 0.1.0-beta.74

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 (432) hide show
  1. package/LICENSE +36 -0
  2. package/NOTICE +7 -0
  3. package/README.md +40 -0
  4. package/dist/browser/{createResourceDialog-BWJkjiOO.js → createResourceDialog-DI9pL-Yr.js} +2 -2
  5. package/dist/browser/{dialog-CUy_fwx9.js → dialog-BYJGvItc.js} +54 -19
  6. package/dist/browser/{flowChanges-CgSnSqqP.js → flowChanges-RL-H4CjD.js} +306 -48
  7. package/dist/browser/{flowWorkspace-Bf7H-r0t.js → flowWorkspace-BkDsETj0.js} +20745 -20186
  8. package/dist/browser/preview.css +73 -3
  9. package/dist/browser/preview.js +21410 -20607
  10. package/dist/browser/{typeScriptSession-B0hC-C8y.js → typeScriptSession-CSRwWmZm.js} +1 -1
  11. package/dist/browser/ui.css +72 -2
  12. package/dist/browser/workbench.css +73 -3
  13. package/dist/browser/workbench.js +14 -76
  14. package/dist/browser/{workbenchSelect-C3e4J93X.js → workbenchSelect-BcWPWGzw.js} +1 -1
  15. package/dist/command/index.js +34 -34
  16. package/dist/common/authoring.d.ts +1 -1
  17. package/dist/common/authoringFields.d.ts +1 -1
  18. package/dist/common/authoringSchema.d.ts +28 -4
  19. package/dist/common/connector-proxy.d.ts +1 -1
  20. package/dist/common/control-api-conformance.d.ts +1 -1
  21. package/dist/common/control-api-conformance.js +61 -24
  22. package/dist/common/control-api.d.ts +4 -3
  23. package/dist/common/control-api.js +90 -42
  24. package/dist/common/control-requests.d.ts +2 -1
  25. package/dist/common/control-requests.js +204 -42
  26. package/dist/common/cron-trigger.d.ts +1 -1
  27. package/dist/common/decision.d.ts +1 -1
  28. package/dist/common/draftOperations.d.ts +1 -1
  29. package/dist/common/engine-contract.d.ts +3 -2
  30. package/dist/common/event-source-definition.d.ts +1 -1
  31. package/dist/common/flow-agent.d.ts +1 -1
  32. package/dist/{browser → common}/flow-authoring-node.d.ts +3 -3
  33. package/dist/{browser → common}/flow-authoring.js +75 -23
  34. package/dist/{browser → common}/flow-change-schema.d.ts +177 -6
  35. package/dist/{browser → common}/flow-change.d.ts +35 -15
  36. package/dist/{browser → common}/flow-change.js +77 -25
  37. package/dist/common/flow-connectionUsage.d.ts +1 -1
  38. package/dist/common/flow-encoding.d.ts +1 -1
  39. package/dist/common/flow-encoding.js +34 -16
  40. package/dist/common/flow-errorHandling.d.ts +1 -1
  41. package/dist/common/flow-graph.d.ts +1 -1
  42. package/dist/common/flow-modules.d.ts +1 -1
  43. package/dist/common/flow-promptTemplate.d.ts +1 -1
  44. package/dist/common/flow-retry.d.ts +1 -1
  45. package/dist/common/flow-semantics.d.ts +1 -1
  46. package/dist/common/flow-semantics.js +151 -32
  47. package/dist/common/flow-workflowFile.d.ts +1 -1
  48. package/dist/common/flowInspection.d.ts +3 -3
  49. package/dist/common/integration-trigger.d.ts +1 -1
  50. package/dist/common/licenses.md +118 -1
  51. package/dist/common/liveDetails.d.ts +1 -1
  52. package/dist/common/mcp.d.ts +2 -1
  53. package/dist/common/mcp.js +59 -22
  54. package/dist/common/openapi.d.ts +1 -1
  55. package/dist/common/poll-trigger.d.ts +1 -1
  56. package/dist/common/run-results.d.ts +8 -1
  57. package/dist/common/runtime-contract.d.ts +8 -2
  58. package/dist/common/runtime-contract.js +22 -5
  59. package/dist/common/scheduler-checkpoint.d.ts +1 -1
  60. package/dist/common/scheduler-dependencies.d.ts +1 -1
  61. package/dist/common/scheduler-node-execution.d.ts +1 -1
  62. package/dist/common/scheduler.d.ts +2 -2
  63. package/dist/common/scheduler.js +31 -12
  64. package/dist/common/trigger-catalog.d.ts +1 -1
  65. package/dist/common/trigger-config-options.d.ts +1 -1
  66. package/dist/common/trigger-config.d.ts +1 -1
  67. package/dist/common/trigger-contract.d.ts +2 -2
  68. package/dist/common/trigger-localization.d.ts +1 -1
  69. package/dist/common/webhook-response.d.ts +1 -1
  70. package/dist/common/webhook-trigger.d.ts +1 -1
  71. package/dist/common/workflow.d.ts +17 -1
  72. package/dist/common/workflow.js +68 -22
  73. package/dist/index.d.ts +6 -3
  74. package/package.json +7 -6
  75. package/src/base/common/json.ts +90 -0
  76. package/src/canvas/common/commands.ts +15 -0
  77. package/src/connector/common/actionSchema.ts +168 -0
  78. package/src/connector/common/proxy.ts +44 -0
  79. package/src/control/common/api.ts +1505 -0
  80. package/src/control/common/authoring.ts +912 -0
  81. package/src/control/common/authoringCondition.ts +110 -0
  82. package/src/control/common/authoringDiagnosticFields.ts +136 -0
  83. package/src/control/common/authoringExamples.ts +192 -0
  84. package/src/control/common/authoringFields.ts +53 -0
  85. package/src/control/common/authoringLlm.ts +70 -0
  86. package/src/control/common/authoringOpenApi.ts +129 -0
  87. package/src/control/common/authoringSchema.ts +404 -0
  88. package/src/control/common/authoringTools.ts +123 -0
  89. package/src/control/common/authoringTriggers.ts +26 -0
  90. package/src/control/common/checkDecoders.ts +65 -0
  91. package/src/control/common/conformance.ts +1989 -0
  92. package/src/control/common/connectorDecoders.ts +370 -0
  93. package/src/control/common/connectorQueries.ts +61 -0
  94. package/src/control/common/decoding.ts +60 -0
  95. package/src/control/common/draftOperations.ts +61 -0
  96. package/src/control/common/errors.ts +114 -0
  97. package/src/control/common/eventSourceSchemas.ts +13 -0
  98. package/src/control/common/eventSources.ts +186 -0
  99. package/src/control/common/flowDecoders.ts +153 -0
  100. package/src/control/common/flowInspection.ts +181 -0
  101. package/src/control/common/flowNotifications.ts +37 -0
  102. package/src/control/common/liveDetails.ts +124 -0
  103. package/src/control/common/mcp.ts +241 -0
  104. package/src/control/common/mcpConformance.ts +142 -0
  105. package/src/control/common/providerAccess.ts +28 -0
  106. package/src/control/common/providerIconSprite.ts +42 -0
  107. package/src/control/common/publicationDecoders.ts +98 -0
  108. package/src/control/common/random.ts +16 -0
  109. package/src/control/common/requests.ts +112 -0
  110. package/src/control/common/resultQuery.ts +8 -0
  111. package/src/control/common/results.ts +176 -0
  112. package/src/control/common/revisionDecoders.ts +52 -0
  113. package/src/control/common/runDecoders.ts +240 -0
  114. package/src/control/common/triggerCatalog.ts +89 -0
  115. package/src/control/common/triggerDecoders.ts +175 -0
  116. package/src/decision/common/decision.ts +168 -0
  117. package/src/event-source/common/catalog.generated.json +65 -0
  118. package/src/event-source/common/definition.ts +66 -0
  119. package/src/event-source/common/index.ts +99 -0
  120. package/src/execution/common/checkpoint.ts +311 -0
  121. package/src/execution/common/dependencies.ts +440 -0
  122. package/src/execution/common/engineContract.ts +80 -0
  123. package/src/execution/common/events.ts +328 -0
  124. package/src/execution/common/node-execution.ts +467 -0
  125. package/src/execution/common/runLifecycle.ts +250 -0
  126. package/src/execution/common/runtime.ts +563 -0
  127. package/src/execution/common/scheduler.ts +696 -0
  128. package/src/execution/common/wait.ts +36 -0
  129. package/src/flow/common/agent.ts +305 -0
  130. package/src/flow/common/authoring.ts +30 -0
  131. package/src/flow/common/change.ts +1036 -0
  132. package/src/flow/common/changeSchema.ts +783 -0
  133. package/src/flow/common/codePermissions.ts +2 -0
  134. package/src/flow/common/condition.ts +265 -0
  135. package/src/flow/common/connectionUsage.ts +131 -0
  136. package/src/flow/common/edgeChanges.ts +35 -0
  137. package/src/flow/common/encoding.ts +437 -0
  138. package/src/flow/common/errorHandling.ts +34 -0
  139. package/src/flow/common/graph.ts +892 -0
  140. package/src/flow/common/inputValue.ts +16 -0
  141. package/src/flow/common/json.ts +8 -0
  142. package/src/flow/common/moduleChanges.ts +31 -0
  143. package/src/flow/common/modules.ts +292 -0
  144. package/src/flow/common/nodeChanges.ts +762 -0
  145. package/src/flow/common/portChanges.ts +36 -0
  146. package/src/flow/common/promptTemplate.ts +15 -0
  147. package/src/flow/common/retry.ts +20 -0
  148. package/src/flow/common/schema.ts +162 -0
  149. package/src/flow/common/schemaCompare.ts +176 -0
  150. package/src/flow/common/schemaComparer.ts +178 -0
  151. package/src/flow/common/schemaKeywords.ts +53 -0
  152. package/src/flow/common/semantics.ts +474 -0
  153. package/src/flow/common/sourceField.ts +35 -0
  154. package/src/flow/common/triggerScheduleSchema.ts +8 -0
  155. package/src/flow/common/wait.ts +30 -0
  156. package/src/flow/common/webhookMethod.ts +7 -0
  157. package/src/flow/common/webhookResponse.ts +75 -0
  158. package/src/flow/common/workflowFile.ts +97 -0
  159. package/src/json-schema-subset/checker/array.ts +142 -0
  160. package/src/json-schema-subset/checker/cache.ts +57 -0
  161. package/src/json-schema-subset/checker/checker.ts +230 -0
  162. package/src/json-schema-subset/checker/enumAndConst.ts +113 -0
  163. package/src/json-schema-subset/checker/equals.ts +93 -0
  164. package/src/json-schema-subset/checker/numeric/checker.ts +50 -0
  165. package/src/json-schema-subset/checker/numeric/float.ts +121 -0
  166. package/src/json-schema-subset/checker/numeric/index.ts +1 -0
  167. package/src/json-schema-subset/checker/numeric/integer.ts +119 -0
  168. package/src/json-schema-subset/checker/numeric/integerNormalizer.ts +72 -0
  169. package/src/json-schema-subset/checker/object.ts +354 -0
  170. package/src/json-schema-subset/checker/range.ts +91 -0
  171. package/src/json-schema-subset/checker/string.ts +60 -0
  172. package/src/json-schema-subset/checker/tools/index.ts +3 -0
  173. package/src/json-schema-subset/checker/tools/mark.ts +82 -0
  174. package/src/json-schema-subset/checker/tools/quantum.ts +106 -0
  175. package/src/json-schema-subset/checker/tools/range.ts +210 -0
  176. package/src/json-schema-subset/checker/validator.ts +23 -0
  177. package/src/json-schema-subset/compiler/array.ts +163 -0
  178. package/src/json-schema-subset/compiler/combination.ts +109 -0
  179. package/src/json-schema-subset/compiler/common.ts +99 -0
  180. package/src/json-schema-subset/compiler/compiler.ts +188 -0
  181. package/src/json-schema-subset/compiler/enumAndConst.ts +75 -0
  182. package/src/json-schema-subset/compiler/index.ts +11 -0
  183. package/src/json-schema-subset/compiler/object.ts +411 -0
  184. package/src/json-schema-subset/compiler/primitive.ts +144 -0
  185. package/src/json-schema-subset/compiler/utils.ts +24 -0
  186. package/src/json-schema-subset/compiler/wrapper.ts +192 -0
  187. package/src/json-schema-subset/error.ts +55 -0
  188. package/src/json-schema-subset/expression/calculator.ts +442 -0
  189. package/src/json-schema-subset/expression/combination.ts +49 -0
  190. package/src/json-schema-subset/expression/expression.ts +191 -0
  191. package/src/json-schema-subset/expression/index.ts +25 -0
  192. package/src/json-schema-subset/expression/result.ts +88 -0
  193. package/src/json-schema-subset/expression/types.ts +36 -0
  194. package/src/json-schema-subset/extends/extendsContext.ts +89 -0
  195. package/src/json-schema-subset/extends/index.ts +2 -0
  196. package/src/json-schema-subset/extends/types.ts +12 -0
  197. package/src/json-schema-subset/global.ts +60 -0
  198. package/src/json-schema-subset/index.ts +8 -0
  199. package/src/json-schema-subset/preprocessing.ts +74 -0
  200. package/src/json-schema-subset/types.ts +8 -0
  201. package/src/json-schema-subset/utils.ts +95 -0
  202. package/src/localization/common/languages.ts +118 -0
  203. package/src/localization/common/timeZone.ts +25 -0
  204. package/src/openapi/common/authBindings.ts +12 -0
  205. package/src/openapi/common/openapi.ts +459 -0
  206. package/src/trigger/common/config.ts +56 -0
  207. package/src/trigger/common/configOptions.ts +15 -0
  208. package/src/trigger/common/configSchema.ts +8 -0
  209. package/src/trigger/common/contract.ts +132 -0
  210. package/src/trigger/common/cron.ts +151 -0
  211. package/src/trigger/common/integration.ts +498 -0
  212. package/src/trigger/common/poll.ts +187 -0
  213. package/src/trigger/common/webhook.ts +412 -0
  214. package/src/trigger/providers/airtable/locales/fr.json +18 -0
  215. package/src/trigger/providers/airtable/locales/ja.json +18 -0
  216. package/src/trigger/providers/airtable/locales/ko.json +18 -0
  217. package/src/trigger/providers/airtable/locales/ru.json +18 -0
  218. package/src/trigger/providers/airtable/locales/zh-CN.json +18 -0
  219. package/src/trigger/providers/airtable/locales/zh-TW.json +18 -0
  220. package/src/trigger/providers/asana/locales/fr.json +15 -0
  221. package/src/trigger/providers/asana/locales/ja.json +15 -0
  222. package/src/trigger/providers/asana/locales/ko.json +15 -0
  223. package/src/trigger/providers/asana/locales/ru.json +15 -0
  224. package/src/trigger/providers/asana/locales/zh-CN.json +15 -0
  225. package/src/trigger/providers/asana/locales/zh-TW.json +15 -0
  226. package/src/trigger/providers/calendly/locales/fr.json +16 -0
  227. package/src/trigger/providers/calendly/locales/ja.json +16 -0
  228. package/src/trigger/providers/calendly/locales/ko.json +16 -0
  229. package/src/trigger/providers/calendly/locales/ru.json +16 -0
  230. package/src/trigger/providers/calendly/locales/zh-CN.json +16 -0
  231. package/src/trigger/providers/calendly/locales/zh-TW.json +16 -0
  232. package/src/trigger/providers/catalog.generated.json +4996 -0
  233. package/src/trigger/providers/clickup/locales/fr.json +16 -0
  234. package/src/trigger/providers/clickup/locales/ja.json +16 -0
  235. package/src/trigger/providers/clickup/locales/ko.json +16 -0
  236. package/src/trigger/providers/clickup/locales/ru.json +16 -0
  237. package/src/trigger/providers/clickup/locales/zh-CN.json +16 -0
  238. package/src/trigger/providers/clickup/locales/zh-TW.json +16 -0
  239. package/src/trigger/providers/definitions.ts +19 -0
  240. package/src/trigger/providers/feishu/locales/fr.json +20 -0
  241. package/src/trigger/providers/feishu/locales/ja.json +20 -0
  242. package/src/trigger/providers/feishu/locales/ko.json +20 -0
  243. package/src/trigger/providers/feishu/locales/ru.json +20 -0
  244. package/src/trigger/providers/feishu/locales/zh-CN.json +20 -0
  245. package/src/trigger/providers/feishu/locales/zh-TW.json +20 -0
  246. package/src/trigger/providers/fieldDescriptions.ts +392 -0
  247. package/src/trigger/providers/fieldLabels.ts +256 -0
  248. package/src/trigger/providers/fireflies/locales/fr.json +17 -0
  249. package/src/trigger/providers/fireflies/locales/ja.json +17 -0
  250. package/src/trigger/providers/fireflies/locales/ko.json +17 -0
  251. package/src/trigger/providers/fireflies/locales/ru.json +17 -0
  252. package/src/trigger/providers/fireflies/locales/zh-CN.json +17 -0
  253. package/src/trigger/providers/fireflies/locales/zh-TW.json +17 -0
  254. package/src/trigger/providers/github/locales/fr.json +30 -0
  255. package/src/trigger/providers/github/locales/ja.json +30 -0
  256. package/src/trigger/providers/github/locales/ko.json +30 -0
  257. package/src/trigger/providers/github/locales/ru.json +30 -0
  258. package/src/trigger/providers/github/locales/zh-CN.json +30 -0
  259. package/src/trigger/providers/github/locales/zh-TW.json +30 -0
  260. package/src/trigger/providers/gitlab/locales/fr.json +18 -0
  261. package/src/trigger/providers/gitlab/locales/ja.json +18 -0
  262. package/src/trigger/providers/gitlab/locales/ko.json +18 -0
  263. package/src/trigger/providers/gitlab/locales/ru.json +18 -0
  264. package/src/trigger/providers/gitlab/locales/zh-CN.json +18 -0
  265. package/src/trigger/providers/gitlab/locales/zh-TW.json +18 -0
  266. package/src/trigger/providers/gmail/locales/fr.json +18 -0
  267. package/src/trigger/providers/gmail/locales/ja.json +18 -0
  268. package/src/trigger/providers/gmail/locales/ko.json +18 -0
  269. package/src/trigger/providers/gmail/locales/ru.json +18 -0
  270. package/src/trigger/providers/gmail/locales/zh-CN.json +18 -0
  271. package/src/trigger/providers/gmail/locales/zh-TW.json +18 -0
  272. package/src/trigger/providers/google-calendar/locales/fr.json +15 -0
  273. package/src/trigger/providers/google-calendar/locales/ja.json +15 -0
  274. package/src/trigger/providers/google-calendar/locales/ko.json +15 -0
  275. package/src/trigger/providers/google-calendar/locales/ru.json +15 -0
  276. package/src/trigger/providers/google-calendar/locales/zh-CN.json +15 -0
  277. package/src/trigger/providers/google-calendar/locales/zh-TW.json +15 -0
  278. package/src/trigger/providers/google-drive/locales/fr.json +48 -0
  279. package/src/trigger/providers/google-drive/locales/ja.json +48 -0
  280. package/src/trigger/providers/google-drive/locales/ko.json +48 -0
  281. package/src/trigger/providers/google-drive/locales/ru.json +48 -0
  282. package/src/trigger/providers/google-drive/locales/zh-CN.json +48 -0
  283. package/src/trigger/providers/google-drive/locales/zh-TW.json +48 -0
  284. package/src/trigger/providers/google-sheets/locales/fr.json +19 -0
  285. package/src/trigger/providers/google-sheets/locales/ja.json +19 -0
  286. package/src/trigger/providers/google-sheets/locales/ko.json +19 -0
  287. package/src/trigger/providers/google-sheets/locales/ru.json +19 -0
  288. package/src/trigger/providers/google-sheets/locales/zh-CN.json +19 -0
  289. package/src/trigger/providers/google-sheets/locales/zh-TW.json +19 -0
  290. package/src/trigger/providers/intercom/locales/fr.json +17 -0
  291. package/src/trigger/providers/intercom/locales/ja.json +17 -0
  292. package/src/trigger/providers/intercom/locales/ko.json +17 -0
  293. package/src/trigger/providers/intercom/locales/ru.json +17 -0
  294. package/src/trigger/providers/intercom/locales/zh-CN.json +17 -0
  295. package/src/trigger/providers/intercom/locales/zh-TW.json +17 -0
  296. package/src/trigger/providers/jira/locales/fr.json +17 -0
  297. package/src/trigger/providers/jira/locales/ja.json +17 -0
  298. package/src/trigger/providers/jira/locales/ko.json +17 -0
  299. package/src/trigger/providers/jira/locales/ru.json +17 -0
  300. package/src/trigger/providers/jira/locales/zh-CN.json +17 -0
  301. package/src/trigger/providers/jira/locales/zh-TW.json +17 -0
  302. package/src/trigger/providers/linear/locales/fr.json +13 -0
  303. package/src/trigger/providers/linear/locales/ja.json +13 -0
  304. package/src/trigger/providers/linear/locales/ko.json +13 -0
  305. package/src/trigger/providers/linear/locales/ru.json +13 -0
  306. package/src/trigger/providers/linear/locales/zh-CN.json +13 -0
  307. package/src/trigger/providers/linear/locales/zh-TW.json +13 -0
  308. package/src/trigger/providers/locales/fr.ts +71 -0
  309. package/src/trigger/providers/locales/ja.ts +71 -0
  310. package/src/trigger/providers/locales/ko.ts +71 -0
  311. package/src/trigger/providers/locales/ru.ts +71 -0
  312. package/src/trigger/providers/locales/zh-CN.ts +71 -0
  313. package/src/trigger/providers/locales/zh-TW.ts +71 -0
  314. package/src/trigger/providers/localization.ts +59 -0
  315. package/src/trigger/providers/notion/locales/fr.json +16 -0
  316. package/src/trigger/providers/notion/locales/ja.json +16 -0
  317. package/src/trigger/providers/notion/locales/ko.json +16 -0
  318. package/src/trigger/providers/notion/locales/ru.json +16 -0
  319. package/src/trigger/providers/notion/locales/zh-CN.json +16 -0
  320. package/src/trigger/providers/notion/locales/zh-TW.json +16 -0
  321. package/src/trigger/providers/one-drive/locales/fr.json +16 -0
  322. package/src/trigger/providers/one-drive/locales/ja.json +16 -0
  323. package/src/trigger/providers/one-drive/locales/ko.json +16 -0
  324. package/src/trigger/providers/one-drive/locales/ru.json +16 -0
  325. package/src/trigger/providers/one-drive/locales/zh-CN.json +16 -0
  326. package/src/trigger/providers/one-drive/locales/zh-TW.json +16 -0
  327. package/src/trigger/providers/outlook/locales/fr.json +18 -0
  328. package/src/trigger/providers/outlook/locales/ja.json +18 -0
  329. package/src/trigger/providers/outlook/locales/ko.json +18 -0
  330. package/src/trigger/providers/outlook/locales/ru.json +18 -0
  331. package/src/trigger/providers/outlook/locales/zh-CN.json +18 -0
  332. package/src/trigger/providers/outlook/locales/zh-TW.json +18 -0
  333. package/src/trigger/providers/pandadoc/locales/fr.json +12 -0
  334. package/src/trigger/providers/pandadoc/locales/ja.json +12 -0
  335. package/src/trigger/providers/pandadoc/locales/ko.json +12 -0
  336. package/src/trigger/providers/pandadoc/locales/ru.json +12 -0
  337. package/src/trigger/providers/pandadoc/locales/zh-CN.json +12 -0
  338. package/src/trigger/providers/pandadoc/locales/zh-TW.json +12 -0
  339. package/src/trigger/providers/pipedrive/locales/fr.json +15 -0
  340. package/src/trigger/providers/pipedrive/locales/ja.json +15 -0
  341. package/src/trigger/providers/pipedrive/locales/ko.json +15 -0
  342. package/src/trigger/providers/pipedrive/locales/ru.json +15 -0
  343. package/src/trigger/providers/pipedrive/locales/zh-CN.json +15 -0
  344. package/src/trigger/providers/pipedrive/locales/zh-TW.json +15 -0
  345. package/src/trigger/providers/remote.ts +163 -0
  346. package/src/trigger/providers/resend/locales/fr.json +16 -0
  347. package/src/trigger/providers/resend/locales/ja.json +16 -0
  348. package/src/trigger/providers/resend/locales/ko.json +16 -0
  349. package/src/trigger/providers/resend/locales/ru.json +16 -0
  350. package/src/trigger/providers/resend/locales/zh-CN.json +16 -0
  351. package/src/trigger/providers/resend/locales/zh-TW.json +16 -0
  352. package/src/trigger/providers/sentry/locales/fr.json +19 -0
  353. package/src/trigger/providers/sentry/locales/ja.json +19 -0
  354. package/src/trigger/providers/sentry/locales/ko.json +19 -0
  355. package/src/trigger/providers/sentry/locales/ru.json +19 -0
  356. package/src/trigger/providers/sentry/locales/zh-CN.json +19 -0
  357. package/src/trigger/providers/sentry/locales/zh-TW.json +19 -0
  358. package/src/trigger/providers/shopify/locales/fr.json +18 -0
  359. package/src/trigger/providers/shopify/locales/ja.json +18 -0
  360. package/src/trigger/providers/shopify/locales/ko.json +18 -0
  361. package/src/trigger/providers/shopify/locales/ru.json +18 -0
  362. package/src/trigger/providers/shopify/locales/zh-CN.json +18 -0
  363. package/src/trigger/providers/shopify/locales/zh-TW.json +18 -0
  364. package/src/trigger/providers/slack/locales/fr.json +18 -0
  365. package/src/trigger/providers/slack/locales/ja.json +18 -0
  366. package/src/trigger/providers/slack/locales/ko.json +18 -0
  367. package/src/trigger/providers/slack/locales/ru.json +18 -0
  368. package/src/trigger/providers/slack/locales/zh-CN.json +18 -0
  369. package/src/trigger/providers/slack/locales/zh-TW.json +18 -0
  370. package/src/trigger/providers/slackbot/locales/fr.json +23 -0
  371. package/src/trigger/providers/slackbot/locales/ja.json +23 -0
  372. package/src/trigger/providers/slackbot/locales/ko.json +23 -0
  373. package/src/trigger/providers/slackbot/locales/ru.json +23 -0
  374. package/src/trigger/providers/slackbot/locales/zh-CN.json +23 -0
  375. package/src/trigger/providers/slackbot/locales/zh-TW.json +23 -0
  376. package/src/trigger/providers/source-events.ts +25 -0
  377. package/src/trigger/providers/stripe/locales/fr.json +17 -0
  378. package/src/trigger/providers/stripe/locales/ja.json +17 -0
  379. package/src/trigger/providers/stripe/locales/ko.json +17 -0
  380. package/src/trigger/providers/stripe/locales/ru.json +17 -0
  381. package/src/trigger/providers/stripe/locales/zh-CN.json +17 -0
  382. package/src/trigger/providers/stripe/locales/zh-TW.json +17 -0
  383. package/src/trigger/providers/tally/locales/fr.json +12 -0
  384. package/src/trigger/providers/tally/locales/ja.json +12 -0
  385. package/src/trigger/providers/tally/locales/ko.json +12 -0
  386. package/src/trigger/providers/tally/locales/ru.json +12 -0
  387. package/src/trigger/providers/tally/locales/zh-CN.json +12 -0
  388. package/src/trigger/providers/tally/locales/zh-TW.json +12 -0
  389. package/src/trigger/providers/telegram/locales/fr.json +17 -0
  390. package/src/trigger/providers/telegram/locales/ja.json +17 -0
  391. package/src/trigger/providers/telegram/locales/ko.json +17 -0
  392. package/src/trigger/providers/telegram/locales/ru.json +17 -0
  393. package/src/trigger/providers/telegram/locales/zh-CN.json +17 -0
  394. package/src/trigger/providers/telegram/locales/zh-TW.json +17 -0
  395. package/src/trigger/providers/typeform/locales/fr.json +12 -0
  396. package/src/trigger/providers/typeform/locales/ja.json +12 -0
  397. package/src/trigger/providers/typeform/locales/ko.json +12 -0
  398. package/src/trigger/providers/typeform/locales/ru.json +12 -0
  399. package/src/trigger/providers/typeform/locales/zh-CN.json +12 -0
  400. package/src/trigger/providers/typeform/locales/zh-TW.json +12 -0
  401. package/src/trigger/providers/vercel/locales/fr.json +18 -0
  402. package/src/trigger/providers/vercel/locales/ja.json +18 -0
  403. package/src/trigger/providers/vercel/locales/ko.json +18 -0
  404. package/src/trigger/providers/vercel/locales/ru.json +18 -0
  405. package/src/trigger/providers/vercel/locales/zh-CN.json +18 -0
  406. package/src/trigger/providers/vercel/locales/zh-TW.json +18 -0
  407. package/src/trigger/providers/woocommerce/locales/fr.json +19 -0
  408. package/src/trigger/providers/woocommerce/locales/ja.json +19 -0
  409. package/src/trigger/providers/woocommerce/locales/ko.json +19 -0
  410. package/src/trigger/providers/woocommerce/locales/ru.json +19 -0
  411. package/src/trigger/providers/woocommerce/locales/zh-CN.json +19 -0
  412. package/src/trigger/providers/woocommerce/locales/zh-TW.json +19 -0
  413. package/src/trigger/providers/zendesk/locales/fr.json +15 -0
  414. package/src/trigger/providers/zendesk/locales/ja.json +15 -0
  415. package/src/trigger/providers/zendesk/locales/ko.json +15 -0
  416. package/src/trigger/providers/zendesk/locales/ru.json +15 -0
  417. package/src/trigger/providers/zendesk/locales/zh-CN.json +15 -0
  418. package/src/trigger/providers/zendesk/locales/zh-TW.json +15 -0
  419. package/src/trigger/providers/zoom/locales/fr.json +17 -0
  420. package/src/trigger/providers/zoom/locales/ja.json +17 -0
  421. package/src/trigger/providers/zoom/locales/ko.json +17 -0
  422. package/src/trigger/providers/zoom/locales/ru.json +17 -0
  423. package/src/trigger/providers/zoom/locales/zh-CN.json +17 -0
  424. package/src/trigger/providers/zoom/locales/zh-TW.json +17 -0
  425. package/src/types/index.ts +112 -0
  426. package/src/workflow/common/workflow.ts +273 -0
  427. package/dist/browser/lib-DrKav3HG.js +0 -11403
  428. /package/dist/{browser → common}/flow-authoring-edge.d.ts +0 -0
  429. /package/dist/{browser → common}/flow-authoring-module.d.ts +0 -0
  430. /package/dist/{browser → common}/flow-authoring.d.ts +0 -0
  431. /package/dist/{browser → common}/flow-input-value.d.ts +0 -0
  432. /package/dist/{browser → common}/webhook-method.d.ts +0 -0
@@ -0,0 +1,1989 @@
1
+ import type { JsonValue, RevisionContent, TriggerKeySnapshot } from '../../flow/common/change.ts'
2
+ import type { TriggerConfigOption } from '../../trigger/common/configOptions.ts'
3
+ import type { ConnectorConnection, CreateEventSource, ResultInfo, PollTriggerTestResult } from './api.ts'
4
+ import type { ProviderAccessIdentity } from './providerAccess.ts'
5
+
6
+ import { dequal } from 'dequal/lite'
7
+ import { currentEngineContract as engineContract } from '../../execution/common/engineContract.ts'
8
+ import { upgradeRevisionContent } from '../../flow/common/change.ts'
9
+ import { ControlClient, decodeRunEvent } from './api.ts'
10
+ import { allConnectorConnectionsQuery } from './connectorQueries.ts'
11
+
12
+ export interface ControlApiConformanceHarness {
13
+ readonly origin: string
14
+ dispose(): Promise<void>
15
+ request(request: Request): Promise<Response>
16
+ }
17
+
18
+ export interface ControlApiConformanceCase {
19
+ readonly name: string
20
+ readonly runtime?: true
21
+ verify(harness: ControlApiConformanceHarness): Promise<void>
22
+ }
23
+
24
+ type RecordValue = Readonly<Record<string, unknown>>
25
+
26
+ function fail(message: string): never {
27
+ throw new Error(message)
28
+ }
29
+
30
+ function equal(actual: unknown, expected: unknown, message: string): void {
31
+ if (!dequal(actual, expected)) fail(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`)
32
+ }
33
+
34
+ function record(value: unknown, message: string): RecordValue {
35
+ if (value == null || typeof value != 'object' || Array.isArray(value)) fail(`${message}: expected an object.`)
36
+ return value as RecordValue
37
+ }
38
+
39
+ function list(value: unknown, message: string): readonly unknown[] {
40
+ if (!Array.isArray(value)) fail(`${message}: expected an array.`)
41
+ return value
42
+ }
43
+
44
+ function requiredString(value: unknown, message: string): string {
45
+ if (typeof value != 'string' || value.length == 0) fail(`${message}: expected a non-empty string.`)
46
+ return value
47
+ }
48
+
49
+ async function json(response: Response, status: number, message: string): Promise<RecordValue> {
50
+ equal(response.status, status, `${message} status`)
51
+ return record(await response.json().catch(() => fail(`${message} body: expected JSON.`)), `${message} body`)
52
+ }
53
+
54
+ async function error(response: Response, status: number, code: string, message: string, detail?: string): Promise<void> {
55
+ const body = await json(response, status, message)
56
+ const bodyError = record(body.error, `${message} error`)
57
+ equal(bodyError.code, code, `${message} error code`)
58
+ const errorMessage = requiredString(bodyError.message, `${message} error message`)
59
+ if (detail != null && !errorMessage.includes(detail)) fail(`${message} error message: expected ${JSON.stringify(detail)} in ${JSON.stringify(errorMessage)}.`)
60
+ equal(body.version, 1, `${message} version`)
61
+ }
62
+
63
+ function request(harness: ControlApiConformanceHarness, path: string, init: RequestInit = {}): Promise<Response> {
64
+ const headers = new Headers(init.headers)
65
+ if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
66
+ return harness.request(new Request(new URL(path, harness.origin), { ...init, headers }))
67
+ }
68
+
69
+ function client(harness: ControlApiConformanceHarness): ControlClient {
70
+ return new ControlClient((path, init) => request(harness, path, init))
71
+ }
72
+
73
+ async function revalidate(harness: ControlApiConformanceHarness, path: string, locale?: string): Promise<void> {
74
+ const response = await request(harness, path)
75
+ await json(response, 200, `Read ${path}`)
76
+ const etag = requiredString(response.headers.get('etag'), `${path} ETag`)
77
+ equal(response.headers.get('cache-control'), 'private, no-cache', `${path} cache policy`)
78
+ const cached = await request(harness, path, { headers: { 'if-none-match': etag } })
79
+ equal(cached.status, 304, `${path} conditional status`)
80
+ equal(await cached.text(), '', `${path} conditional body`)
81
+ equal(cached.headers.get('etag'), etag, `${path} conditional ETag`)
82
+ equal(cached.headers.get('cache-control'), 'private, no-cache', `${path} conditional cache policy`)
83
+ if (locale != null) {
84
+ for (const result of [response, cached]) {
85
+ equal(result.headers.get('content-language'), locale, `${path} language`)
86
+ if (!result.headers.get('vary')?.toLowerCase().split(/,\s*/).includes('accept-language')) fail(`${path} must vary by Accept-Language.`)
87
+ }
88
+ }
89
+ }
90
+
91
+ function createFlowRequest(harness: ControlApiConformanceHarness, name: string, key: string): Promise<Response> {
92
+ return request(harness, '/v1/flows', {
93
+ body: JSON.stringify({ name, version: 1 }),
94
+ headers: { 'idempotency-key': key },
95
+ method: 'POST',
96
+ })
97
+ }
98
+
99
+ async function createFlow(harness: ControlApiConformanceHarness, name: string, key: string): Promise<RecordValue> {
100
+ return json(await createFlowRequest(harness, name, key), 201, 'Create Flow')
101
+ }
102
+
103
+ function changeRequest(
104
+ harness: ControlApiConformanceHarness,
105
+ flowId: string,
106
+ revisionId: string,
107
+ operations: readonly unknown[],
108
+ changeId = `change-${crypto.randomUUID()}`,
109
+ ): Promise<Response> {
110
+ return request(harness, `/v1/flows/${encodeURIComponent(flowId)}/draft/changes`, {
111
+ body: JSON.stringify({ expectedRevisionId: revisionId, operations, version: 1 }),
112
+ headers: { 'idempotency-key': changeId },
113
+ method: 'POST',
114
+ })
115
+ }
116
+
117
+ function addValueNode(harness: ControlApiConformanceHarness, flowId: string, revisionId: string, nodeId = 'marker', changeId?: string): Promise<Response> {
118
+ return changeRequest(
119
+ harness,
120
+ flowId,
121
+ revisionId,
122
+ [
123
+ {
124
+ kind: 'graph.node.create',
125
+ node: {
126
+ inputs: {},
127
+ kind: 'value',
128
+ name: nodeId,
129
+ values: [{ handle: 'ready', jsonSchema: { type: 'boolean' }, nullable: false, value: true }],
130
+ },
131
+ nodeId,
132
+ },
133
+ ],
134
+ changeId,
135
+ )
136
+ }
137
+
138
+ async function addStartTrigger(harness: ControlApiConformanceHarness, flowId: string, revisionId: string): Promise<string> {
139
+ return changedRevisionId(
140
+ await json(
141
+ await changeRequest(harness, flowId, revisionId, [{ kind: 'graph.node.create', node: { inputDefs: [], kind: 'start', name: 'Start' }, nodeId: 'start' }]),
142
+ 200,
143
+ 'Add Start node',
144
+ ),
145
+ 'Start Revision',
146
+ )
147
+ }
148
+
149
+ function changedRevisionId(change: RecordValue, message: string): string {
150
+ return requiredString(record(change.revision, `${message} revision`).revisionId, `${message} revisionId`)
151
+ }
152
+
153
+ function publishRequest(
154
+ harness: ControlApiConformanceHarness,
155
+ flowId: string,
156
+ revisionId: string,
157
+ expectedLivePublicationId: string | null,
158
+ key: string,
159
+ ): Promise<Response> {
160
+ return request(harness, `/v1/flows/${encodeURIComponent(flowId)}/revisions/${encodeURIComponent(revisionId)}/publications`, {
161
+ body: JSON.stringify({ engineContract, expectedLivePublicationId, version: 1 }),
162
+ headers: { 'idempotency-key': key },
163
+ method: 'POST',
164
+ })
165
+ }
166
+
167
+ async function completePublish(
168
+ harness: ControlApiConformanceHarness,
169
+ response: Response,
170
+ status: 202,
171
+ message: string,
172
+ ): Promise<{ readonly operation: RecordValue; readonly publication: RecordValue }> {
173
+ let operation = await json(response, status, message)
174
+ const operationId = requiredString(operation.operationId, `${message} operationId`)
175
+ for (let attempt = 0; operation.status == 'pending' && attempt < 100; attempt += 1) {
176
+ await new Promise((resolve) => setTimeout(resolve, 10))
177
+ operation = await json(
178
+ await request(harness, `/v1/flows/${requiredString(operation.flowId, `${message} flowId`)}/publish-operations/${operationId}`),
179
+ 200,
180
+ `${message} operation`,
181
+ )
182
+ }
183
+ equal(operation.status, 'succeeded', `${message} terminal status`)
184
+ const publicationId = requiredString(operation.publicationId, `${message} publicationId`)
185
+ const live = await json(await request(harness, `/v1/flows/${requiredString(operation.flowId, `${message} flowId`)}/live`), 200, `${message} Live`)
186
+ const publication = record(live.publication, `${message} Publication`)
187
+ equal(publication.publicationId, publicationId, `${message} Live Publication`)
188
+ return { operation, publication }
189
+ }
190
+
191
+ function rollbackRequest(harness: ControlApiConformanceHarness, flowId: string, source: string, expected: string, key: string): Promise<Response> {
192
+ return request(harness, `/v1/flows/${encodeURIComponent(flowId)}/publications/${encodeURIComponent(source)}/rollback`, {
193
+ body: JSON.stringify({ expectedLivePublicationId: expected, version: 1 }),
194
+ headers: { 'idempotency-key': key },
195
+ method: 'POST',
196
+ })
197
+ }
198
+
199
+ function liveRunRequest(harness: ControlApiConformanceHarness, publicationId: string, key: string): Promise<Response> {
200
+ return request(harness, '/v1/runs', {
201
+ body: JSON.stringify({ inputs: {}, trigger: { nodeId: 'start', outputs: {} }, publicationId, version: 2 }),
202
+ headers: { 'idempotency-key': key },
203
+ method: 'POST',
204
+ })
205
+ }
206
+
207
+ export const controlApiConformanceCases: readonly ControlApiConformanceCase[] = [
208
+ {
209
+ name: 'imports workflow files atomically and exports a new unpublished draft with its Presentation',
210
+ async verify(harness) {
211
+ const api = new ControlClient((url, init) => harness.request(new Request(new URL(url, harness.origin), init)))
212
+ const file = {
213
+ kind: 'open-flow-workflow' as const,
214
+ version: 1 as const,
215
+ name: 'Portable flow',
216
+ content: {
217
+ modelVersion: 6 as const,
218
+ modules: {},
219
+ document: { bindings: {}, graph: { nodes: { start: { kind: 'manual' as const, name: 'Start' } }, edges: [] } },
220
+ },
221
+ presentation: { viewport: { x: 10, y: 20, zoom: 1 } },
222
+ }
223
+ const idempotencyKey = crypto.randomUUID()
224
+ const created = await api.importFlow(file, { idempotencyKey })
225
+ equal(dequal(await api.importFlow(file, { idempotencyKey }), created), true, 'Import retry must return the same Flow.')
226
+ const exported = await api.exportFlow(created.flowId)
227
+ equal(
228
+ dequal(exported, { ...file, content: upgradeRevisionContent(file.content) }),
229
+ true,
230
+ 'Export must upgrade legacy Manual nodes and retain the graph and Presentation.',
231
+ )
232
+ const editor = await api.getEditor(created.flowId)
233
+ equal(editor.live.publication == null, true, 'Import must not publish.')
234
+ equal(editor.draft.parentRevisionId == null, true, 'Import must create an initial Revision.')
235
+ equal(dequal(editor.presentation.value, file.presentation), true, 'Import must atomically save Presentation.')
236
+ await error(
237
+ await request(harness, '/v1/flows/import', {
238
+ method: 'POST',
239
+ headers: { 'idempotency-key': idempotencyKey },
240
+ body: JSON.stringify({ file, name: 'Changed', version: 1 }),
241
+ }),
242
+ 409,
243
+ 'flow.conflict',
244
+ 'Reject changed import retry',
245
+ )
246
+ const before = await api.listFlows()
247
+ await error(
248
+ await request(harness, '/v1/flows/import', {
249
+ method: 'POST',
250
+ headers: { 'idempotency-key': crypto.randomUUID() },
251
+ body: JSON.stringify({ file: { ...file, version: 99 }, version: 1 }),
252
+ }),
253
+ 400,
254
+ 'flow.invalid',
255
+ 'Reject unsupported file',
256
+ )
257
+ equal(dequal((await api.listFlows()).flows, before.flows), true, 'Rejected import left an empty Flow.')
258
+ },
259
+ },
260
+ {
261
+ name: 'authors nodes atomically with public views, fixed reads and replay before CAS',
262
+ async verify(harness) {
263
+ const api = client(harness),
264
+ flow = await api.createFlow('Node authoring')
265
+ const input = {
266
+ baseRevision: flow.draftRevisionId,
267
+ requestId: 'semantic-create',
268
+ edits: [{ op: 'node.add' as const, type: 'code' as const, as: 'code', name: 'Format', code: 'export default () => ({value:1})' }],
269
+ }
270
+ const first = await api.editAuthoring(flow.flowId, input),
271
+ ref = first.nodes.code!
272
+ const detail = await api.readAuthoring(flow.flowId, { nodes: [ref], revision: first.revision })
273
+ if (/"(?:taskId|moduleId|bindingId)"/.test(JSON.stringify(detail))) fail('Public node view leaked storage identities')
274
+ equal((await api.searchAuthoring(flow.flowId, { query: 'Format' })).data.matches[0]?.node, ref, 'Search reference')
275
+ const changed = await api.editAuthoring(flow.flowId, {
276
+ baseRevision: first.revision,
277
+ requestId: 'semantic-text',
278
+ edits: [{ op: 'text.edit', node: ref, field: 'code', oldText: 'value:1', newText: 'value:2' }],
279
+ })
280
+ equal(
281
+ (await api.readAuthoring(flow.flowId, { revision: first.revision, text: { node: ref, field: 'code' } })).data.text?.content,
282
+ 'export default () => ({value:1})',
283
+ 'Immutable text',
284
+ )
285
+ const replay = await api.editAuthoring(flow.flowId, input)
286
+ equal(replay.revision, first.revision, 'Replay receipt after head advances')
287
+ equal(replay.nodes, first.nodes, 'Stable alias identities')
288
+ const post = (value: unknown) => request(harness, `/v1/flows/${flow.flowId}/authoring/edit`, { method: 'POST', body: JSON.stringify(value) })
289
+ await error(await post({ ...input, requestId: 'stale' }), 412, 'flow.revision-conflict', 'Stale semantic edit')
290
+ await error(await post({ ...input, edits: [{ op: 'node.remove', node: ref }] }), 409, 'flow.conflict', 'Request identity conflict')
291
+ await error(
292
+ await post({
293
+ baseRevision: changed.revision,
294
+ requestId: 'partial',
295
+ edits: [
296
+ { op: 'node.update', node: ref, set: { name: 'Changed' } },
297
+ { op: 'text.edit', node: ref, field: 'code', oldText: 'absent', newText: 'new' },
298
+ ],
299
+ }),
300
+ 400,
301
+ 'flow.invalid',
302
+ 'Atomic text failure',
303
+ )
304
+ equal((await api.getFlow(flow.flowId)).draftRevisionId, changed.revision, 'Rejected batch preserves head')
305
+ // Workbench consumes the same committed Revision through its normal decoder.
306
+ const draft = await api.getDraft(flow.flowId)
307
+ equal(draft.content.document.graph.nodes[ref]?.name, 'Format', 'Workbench node identity')
308
+ const check = await api.checkAuthoring(flow.flowId, changed.revision)
309
+ equal(check.revisionId, changed.revision, 'Fixed check identity')
310
+ },
311
+ },
312
+ {
313
+ name: 'authors Start parameters and admits their shared call contract',
314
+ async verify(harness) {
315
+ const api = client(harness),
316
+ flow = await api.createFlow('Start parameters')
317
+ const schema = await api.authoringSchema(flow.flowId, { type: 'start' })
318
+ equal(schema.type, 'start', 'Start discovery')
319
+ const created = await api.editAuthoring(flow.flowId, {
320
+ baseRevision: flow.draftRevisionId,
321
+ requestId: 'start-parameters-create',
322
+ edits: [
323
+ {
324
+ op: 'node.add',
325
+ as: 'start',
326
+ type: 'start',
327
+ name: 'Start',
328
+ config: {
329
+ inputDefs: {
330
+ message: { schema: { type: 'string' }, nullable: false, description: 'Original' },
331
+ note: { schema: { type: 'string' }, nullable: true },
332
+ },
333
+ },
334
+ },
335
+ ],
336
+ })
337
+ const nodeId = created.nodes.start!
338
+ const changed = await api.editAuthoring(flow.flowId, {
339
+ baseRevision: created.revision,
340
+ requestId: 'start-parameters-update',
341
+ edits: [{ op: 'node.update', node: nodeId, set: { config: { inputDefs: { message: { description: 'Call message' } } } } }],
342
+ })
343
+ const details = await api.readAuthoring(flow.flowId, { nodes: [nodeId] })
344
+ equal(
345
+ (details.data.nodes?.[0]?.config as Record<string, unknown> | undefined)?.inputDefs,
346
+ {
347
+ message: { schema: { type: 'string' }, nullable: false, description: 'Call message' },
348
+ note: { schema: { type: 'string' }, nullable: true },
349
+ },
350
+ 'Local parameter update preserves the contract',
351
+ )
352
+ const runPath = `/v1/flows/${flow.flowId}/revisions/${changed.revision}/runs`
353
+ const run = (outputs: unknown, key: string) =>
354
+ request(harness, runPath, {
355
+ method: 'POST',
356
+ headers: { 'idempotency-key': key },
357
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId, outputs }, version: 2 }),
358
+ })
359
+ for (const [index, outputs] of [{}, { message: 0 }, { message: 'Hello', extra: true }].entries())
360
+ await error(await run(outputs, `start-invalid-${index}`), 400, 'run.invalid', 'Reject invalid Start parameters')
361
+ const admitted = await json(await run({ message: 'Hello' }, 'start-call'), 202, 'Admit omitted nullable parameter')
362
+ equal(await json(await run({ message: 'Hello' }, 'start-call'), 200, 'Replay Start call'), admitted, 'Stable Start admission')
363
+ await error(await run({ message: 'Hello', note: null }, 'start-call'), 409, 'run.conflict', 'Preserve original request identity before normalization')
364
+ await json(await run({ message: 'Hello', note: null }, 'start-explicit-null'), 202, 'Admit explicit null')
365
+ const old = await api.readAuthoring(flow.flowId, { revision: created.revision, nodes: [nodeId] })
366
+ equal(
367
+ (old.data.nodes?.[0]?.config as Record<string, unknown> | undefined)?.inputDefs,
368
+ {
369
+ message: { schema: { type: 'string' }, nullable: false, description: 'Original' },
370
+ note: { schema: { type: 'string' }, nullable: true },
371
+ },
372
+ 'Fixed Start read preserves old configuration',
373
+ )
374
+ },
375
+ },
376
+ {
377
+ name: 'admits simultaneous retries as one Run and preserves its Trigger identity',
378
+ async verify(harness) {
379
+ const flow = await createFlow(harness, 'Concurrent Run', 'concurrent-run-flow')
380
+ const flowId = requiredString(flow.flowId, 'Flow ID')
381
+ const revisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Revision ID'))
382
+ const submit = (payload: unknown = {}) =>
383
+ request(harness, `/v1/flows/${flowId}/revisions/${revisionId}/runs`, {
384
+ method: 'POST',
385
+ headers: { 'idempotency-key': 'concurrent-run' },
386
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: payload }, version: 2 }),
387
+ })
388
+ const responses = await Promise.all([submit(), submit(), submit(), submit()])
389
+ equal(responses.map((response) => response.status).toSorted(), [200, 200, 200, 202], 'One Run is admitted')
390
+ const bodies = await Promise.all(responses.map((response) => response.json()))
391
+ const runId = requiredString(record(bodies[0], 'Run').runId, 'Run ID')
392
+ for (const body of bodies) equal(record(body, 'Run').runId, runId, 'Concurrent Run identity')
393
+ await error(await submit({ changed: true }), 409, 'run.conflict', 'Reject changed Trigger replay')
394
+ const runs = await json(await request(harness, `/v1/flows/${flowId}/runs`), 200, 'List admitted Runs')
395
+ equal(list(runs.runs, 'Runs').length, 1, 'No duplicate Run')
396
+ },
397
+ },
398
+ {
399
+ name: 'deduplicates simultaneous creation and lost-response retries',
400
+ async verify(harness) {
401
+ const responses = await Promise.all(Array.from({ length: 4 }, () => createFlowRequest(harness, 'Concurrent Flow', 'concurrent-create')))
402
+ equal(responses.map((response) => response.status).toSorted(), [200, 200, 200, 201], 'One creation commits')
403
+ const bodies = await Promise.all(responses.map((response) => response.json()))
404
+ for (const body of bodies) equal(body, bodies[0], 'Concurrent replay identity')
405
+ const retried = await json(await createFlowRequest(harness, 'Concurrent Flow', 'concurrent-create'), 200, 'Retry lost creation response')
406
+ equal(retried, bodies[0], 'Lost-response replay')
407
+ await error(await createFlowRequest(harness, 'Different Flow', 'concurrent-create'), 409, 'flow.conflict', 'Reject changed retry')
408
+ },
409
+ },
410
+ {
411
+ name: 'commits one concurrent Draft change and replays the winning key',
412
+ async verify(harness) {
413
+ const created = await createFlow(harness, 'Concurrent Draft', 'concurrent-draft')
414
+ const flowId = requiredString(created.flowId, 'Flow ID')
415
+ const revisionId = requiredString(created.draftRevisionId, 'Revision ID')
416
+ const responses = await Promise.all([
417
+ addValueNode(harness, flowId, revisionId, 'left', 'concurrent-left'),
418
+ addValueNode(harness, flowId, revisionId, 'right', 'concurrent-right'),
419
+ ])
420
+ equal(responses.map((response) => response.status).toSorted(), [200, 412], 'One Draft CAS commits')
421
+ const winner = responses.findIndex((response) => response.status == 200)
422
+ const committed = await json(responses[winner] ?? fail('Missing winning response'), 200, 'Winning Draft change')
423
+ await error(responses[1 - winner] ?? fail('Missing losing response'), 412, 'flow.revision-conflict', 'Losing Draft change')
424
+ const nodeId = winner == 0 ? 'left' : 'right'
425
+ equal(
426
+ await json(await addValueNode(harness, flowId, revisionId, nodeId, `concurrent-${nodeId}`), 200, 'Replay winning change'),
427
+ committed,
428
+ 'Replay precedes stale Revision check',
429
+ )
430
+ const draft = await json(await request(harness, `/v1/flows/${flowId}/draft`), 200, 'Read authoritative Draft')
431
+ const nodes = record(record(record(draft.content, 'Content').document, 'Document').graph, 'Graph').nodes
432
+ equal(Object.keys(record(nodes, 'Nodes')), [nodeId], 'No partial losing edit')
433
+ },
434
+ },
435
+ {
436
+ name: 'manages deployment Variables with stable limits and exact-case identity',
437
+ async verify(harness) {
438
+ equal(await json(await request(harness, '/v1/variables'), 200, 'List empty Variables'), { variables: [], version: 1 }, 'Empty Variables')
439
+ const put = (name: string, value: string, body: Readonly<Record<string, unknown>> = { value }) =>
440
+ request(harness, `/v1/variables/${encodeURIComponent(name)}`, { body: JSON.stringify(body), method: 'PUT' })
441
+ const created = await json(await put('Token', ''), 200, 'Create Variable')
442
+ equal(Object.keys(created).toSorted(), ['name', 'updatedAt', 'value', 'version'], 'Variable fields')
443
+ equal(created.name, 'Token', 'Variable name')
444
+ equal(created.value, '', 'Empty Variable value')
445
+ equal(created.version, 1, 'Variable version')
446
+ requiredString(created.updatedAt, 'Variable updatedAt')
447
+ equal(await json(await request(harness, '/v1/variables/Token'), 200, 'Read Variable'), created, 'Read Variable')
448
+ equal(await json(await put('Token', ''), 200, 'Put same Variable'), created, 'Same Variable update')
449
+
450
+ for (const name of ['A', 'Z', 'a', 'token']) await json(await put(name, name), 200, `Create ${name} Variable`)
451
+ const listed = await json(await request(harness, '/v1/variables'), 200, 'List Variables')
452
+ equal(
453
+ list(listed.variables, 'Variables').map((value) => record(value, 'Variable').name),
454
+ ['A', 'Token', 'Z', 'a', 'token'],
455
+ 'Variable binary order',
456
+ )
457
+
458
+ const large = `${'值'.repeat(21_845)}x`
459
+ equal(new TextEncoder().encode(large).byteLength, 65_536, 'Variable UTF-8 boundary fixture')
460
+ await json(await put('BIG', large), 200, 'Create maximum Variable value')
461
+ await error(await put('BIG', `${large}x`), 400, 'variable.invalid', 'Reject oversized Variable value')
462
+ await error(await put('OO_TOKEN', 'value'), 400, 'variable.invalid', 'Reject reserved Variable name')
463
+ await error(await put('bad-name', 'value'), 400, 'variable.invalid', 'Reject invalid Variable name')
464
+ await error(await put('EXTRA', 'value', { extra: true, value: 'value' }), 400, 'variable.invalid', 'Reject extra Variable field')
465
+ await error(await request(harness, '/v1/variables/MISSING'), 404, 'variable.not-found', 'Read missing Variable')
466
+ await error(await request(harness, '/v1/variables/MISSING', { method: 'DELETE' }), 404, 'variable.not-found', 'Delete missing Variable')
467
+ equal(await json(await request(harness, '/v1/variables/Token', { method: 'DELETE' }), 200, 'Delete Variable'), { version: 1 }, 'Delete response')
468
+
469
+ for (let index = 0; index < 195; index += 1) {
470
+ await json(await put(`V${String(index).padStart(3, '0')}`, 'value'), 200, `Fill Variable ${index}`)
471
+ }
472
+ await error(await put('V195', 'value'), 409, 'variable.limit-reached', 'Reject Variable over limit')
473
+ equal((await json(await put('A', 'updated'), 200, 'Update full Variable catalog')).value, 'updated', 'Full catalog update')
474
+ },
475
+ },
476
+ {
477
+ name: 'creates, replays, lists, reads, renames, and retires a Flow',
478
+ async verify(harness) {
479
+ const created = await createFlow(harness, 'Control flow', 'flow-lifecycle')
480
+ const flowId = requiredString(created.flowId, 'Created Flow flowId')
481
+ const draftRevisionId = requiredString(created.draftRevisionId, 'Created Flow draftRevisionId')
482
+ equal(created.name, 'Control flow', 'Created Flow name')
483
+ equal(created.status, 'active', 'Created Flow status')
484
+ equal(
485
+ record(created.resourceReferences, 'Flow resource references').draft,
486
+ {
487
+ calledFlowIds: [],
488
+ variableNames: [],
489
+ connections: [],
490
+ errorSourceFlowIds: [],
491
+ },
492
+ 'Empty draft resources',
493
+ )
494
+ equal(await json(await createFlowRequest(harness, 'Control flow', 'flow-lifecycle'), 200, 'Replay Flow'), created, 'Replayed Flow')
495
+ await error(await createFlowRequest(harness, 'Different flow', 'flow-lifecycle'), 409, 'flow.conflict', 'Conflicting Flow')
496
+ equal(await json(await request(harness, `/v1/flows/${flowId}`), 200, 'Read Flow'), created, 'Read Flow')
497
+ const page = await json(await request(harness, '/v1/flows?includeTotal=true'), 200, 'List Flows')
498
+ equal(page.flows, [{ ...created, referenceCount: 0 }], 'Listed Flows')
499
+ equal(page.total, 1, 'Flow total')
500
+ const second = await createFlow(harness, 'Second control flow', 'flow-lifecycle-second')
501
+ const firstPage = await json(await request(harness, '/v1/flows?limit=1'), 200, 'List first Flow page')
502
+ equal(firstPage.flows, [{ ...created, referenceCount: 0 }], 'First Flow page')
503
+ const cursor = requiredString(firstPage.nextCursor, 'Flow cursor')
504
+ equal(
505
+ (await json(await request(harness, `/v1/flows?limit=1&cursor=${encodeURIComponent(cursor)}`), 200, 'List second Flow page')).flows,
506
+ [{ ...second, referenceCount: 0 }],
507
+ 'Second Flow page',
508
+ )
509
+ const renamed = await json(
510
+ await request(harness, `/v1/flows/${flowId}`, { body: JSON.stringify({ name: 'Renamed flow', version: 1 }), method: 'PATCH' }),
511
+ 200,
512
+ 'Rename Flow',
513
+ )
514
+ equal(renamed.name, 'Renamed flow', 'Renamed Flow name')
515
+ const retired = await json(await request(harness, `/v1/flows/${flowId}`, { method: 'DELETE' }), 202, 'Retire Flow')
516
+ equal(retired.status, 'retiring', 'Retired Flow status')
517
+ await error(await addValueNode(harness, flowId, draftRevisionId), 409, 'flow.busy', 'Mutation after retirement')
518
+ },
519
+ },
520
+ {
521
+ name: 'commits immutable Draft Revisions for one Flow',
522
+ async verify(harness) {
523
+ const flow = await createFlow(harness, 'Draft flow', 'draft-flow')
524
+ const flowId = requiredString(flow.flowId, 'Draft Flow flowId')
525
+ const initialRevisionId = requiredString(flow.draftRevisionId, 'Draft Flow revisionId')
526
+ const changed = await json(await addValueNode(harness, flowId, initialRevisionId, 'marker', 'draft-change'), 200, 'Change Draft')
527
+ const currentRevisionId = changedRevisionId(changed, 'Changed Draft')
528
+ equal(record(changed.revision, 'Changed Draft revision').parentRevisionId, initialRevisionId, 'Changed Draft parent')
529
+ equal(
530
+ await json(await addValueNode(harness, flowId, initialRevisionId, 'marker', 'draft-change'), 200, 'Replay Draft change'),
531
+ changed,
532
+ 'Replayed Draft change',
533
+ )
534
+ await error(
535
+ await addValueNode(harness, flowId, initialRevisionId, 'another-marker', 'draft-change'),
536
+ 409,
537
+ 'flow.conflict',
538
+ 'Conflicting Draft change replay',
539
+ )
540
+ await error(
541
+ await addValueNode(harness, flowId, currentRevisionId, 'marker'),
542
+ 400,
543
+ 'flow.invalid',
544
+ 'Duplicate Draft Node',
545
+ 'A Node with this ID already exists',
546
+ )
547
+ await error(
548
+ await changeRequest(harness, flowId, currentRevisionId, [
549
+ {
550
+ before: [{ handle: 'ready', jsonSchema: { type: 'boolean' }, nullable: false, value: true }],
551
+ kind: 'graph.node.values.set',
552
+ nodeId: 'marker',
553
+ value: [{ handle: 'ready', jsonSchema: { type: 'boolean' }, nullable: false, value: true }],
554
+ },
555
+ ]),
556
+ 400,
557
+ 'flow.invalid',
558
+ 'No-op Draft change',
559
+ )
560
+ await error(await addValueNode(harness, flowId, initialRevisionId, 'stale'), 412, 'flow.revision-conflict', 'Stale Draft change')
561
+ const old = await json(await request(harness, `/v1/flows/${flowId}/revisions/${initialRevisionId}`), 200, 'Read old Revision')
562
+ const oldNodes = record(record(record(record(old.content, 'Old content').document, 'Old document').graph, 'Old graph').nodes, 'Old nodes')
563
+ equal(oldNodes, {}, 'Old Revision nodes')
564
+ const draft = await json(await request(harness, `/v1/flows/${flowId}/draft`), 200, 'Read Draft')
565
+ equal(draft.revisionId, currentRevisionId, 'Draft revisionId')
566
+ const sync = await json(await request(harness, `/v1/flows/${flowId}/draft/sync`), 200, 'Sync Draft')
567
+ equal(sync.kind, 'snapshot', 'Draft sync kind')
568
+ equal(record(sync.draft, 'Draft snapshot').revisionId, currentRevisionId, 'Draft snapshot revisionId')
569
+ },
570
+ },
571
+ {
572
+ name: 'repairs a Draft with immutable history, idempotency and Revision CAS',
573
+ async verify(harness) {
574
+ const api = client(harness)
575
+ const flow = await api.createFlow('Repair', 'repair-flow')
576
+ const before = await api.getDraft(flow.flowId)
577
+ const presentation = await api.getPresentation(flow.flowId)
578
+ const live = await api.getLive(flow.flowId)
579
+ const repaired = await api.repairDraft(flow.flowId, before.revisionId, 'repair-once')
580
+ equal(repaired.revision.parentRevisionId, before.revisionId, 'Repair parent')
581
+ if (repaired.revision.revisionId == before.revisionId) fail('Repair must create a child Revision.')
582
+ equal(await api.repairDraft(flow.flowId, before.revisionId, 'repair-once'), repaired, 'Repair replay')
583
+ equal(await api.getRevision(flow.flowId, before.revisionId), before, 'Original Revision remains immutable')
584
+ equal((await api.getEditor(flow.flowId)).draft.content, before.content, 'Repair preserves readable content')
585
+ equal(await api.getPresentation(flow.flowId), presentation, 'Repair preserves Presentation')
586
+ equal(await api.getLive(flow.flowId), live, 'Repair preserves Live')
587
+ await error(
588
+ await request(harness, `/v1/flows/${encodeURIComponent(flow.flowId)}/draft/repair`, {
589
+ method: 'POST',
590
+ headers: { 'idempotency-key': 'repair-stale' },
591
+ body: JSON.stringify({ expectedRevisionId: before.revisionId, version: 1 }),
592
+ }),
593
+ 412,
594
+ 'flow.revision-conflict',
595
+ 'Stale repair',
596
+ )
597
+ },
598
+ },
599
+ {
600
+ name: 'loads the current editor without changing Draft or Presentation',
601
+ async verify(harness) {
602
+ const created = await createFlow(harness, 'Editor flow', 'editor-flow')
603
+ const flowId = requiredString(created.flowId, 'Editor Flow identity')
604
+ const initialRevisionId = requiredString(created.draftRevisionId, 'Editor Draft identity')
605
+ const changed = await json(await addValueNode(harness, flowId, initialRevisionId, 'marker'), 200, 'Change editor Draft')
606
+ const layout = await json(
607
+ await request(harness, `/v1/flows/${flowId}/presentation`, {
608
+ method: 'PUT',
609
+ body: JSON.stringify({ expectedRevision: 1, value: { nodes: { marker: { x: 12, y: 24 } } }, version: 1 }),
610
+ }),
611
+ 200,
612
+ 'Update editor Presentation',
613
+ )
614
+ const editor = await json(await request(harness, `/v1/flows/${flowId}/editor`), 200, 'Read editor')
615
+ equal(editor.version, 1, 'Editor version')
616
+ equal(record(editor.flow, 'Editor Flow').draftRevisionId, changedRevisionId(changed, 'Editor change'), 'Editor Draft head')
617
+ for (const key of ['flow', 'draft', 'live', 'presentation']) {
618
+ const path = key == 'flow' ? `/v1/flows/${flowId}` : `/v1/flows/${flowId}/${key}`
619
+ equal(editor[key], await json(await request(harness, path), 200, `Read editor ${key}`), `Editor ${key}`)
620
+ }
621
+ equal(editor.presentation, layout, 'Reading editor preserves Presentation')
622
+ await error(await request(harness, '/v1/flows/missing-editor/editor'), 404, 'flow.not-found', 'Read missing editor')
623
+ },
624
+ },
625
+ {
626
+ name: 'keeps Presentation CAS independent from the Draft head',
627
+ async verify(harness) {
628
+ const flow = await createFlow(harness, 'Presentation flow', 'presentation-flow')
629
+ const flowId = requiredString(flow.flowId, 'Presentation Flow flowId')
630
+ const draftRevisionId = requiredString(flow.draftRevisionId, 'Presentation Flow revisionId')
631
+ const path = `/v1/flows/${flowId}/presentation`
632
+ const value = { edges: [], nodes: { task: { x: 120, y: 80 } } }
633
+ const updated = await json(
634
+ await request(harness, path, { body: JSON.stringify({ expectedRevision: 1, value, version: 1 }), method: 'PUT' }),
635
+ 200,
636
+ 'Update Presentation',
637
+ )
638
+ equal(updated.revision, 2, 'Presentation revision')
639
+ equal(updated.value, value, 'Presentation value')
640
+ await error(
641
+ await request(harness, path, { body: JSON.stringify({ expectedRevision: 1, value: {}, version: 1 }), method: 'PUT' }),
642
+ 412,
643
+ 'flow.presentation-conflict',
644
+ 'Stale Presentation update',
645
+ )
646
+ equal((await json(await request(harness, path), 200, 'Read Presentation')).value, value, 'Presentation value after conflict')
647
+ await json(await request(harness, `/v1/flows/${flowId}`, { method: 'DELETE' }), 202, 'Retire Presentation Flow')
648
+ await error(
649
+ await request(harness, path, { body: JSON.stringify({ expectedRevision: 2, value: {}, version: 1 }), method: 'PUT' }),
650
+ 409,
651
+ 'flow.busy',
652
+ 'Update retired Presentation',
653
+ )
654
+ equal((await json(await request(harness, `/v1/flows/${flowId}`), 200, 'Read Flow')).draftRevisionId, draftRevisionId, 'Draft head')
655
+ },
656
+ },
657
+ {
658
+ name: 'admits a Draft entry while unrelated branches remain invalid',
659
+ async verify(harness) {
660
+ const flow = await createFlow(harness, 'Partial Draft', 'partial-draft')
661
+ const flowId = requiredString(flow.flowId, 'Partial Flow identity')
662
+ const initial = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Partial Draft identity'))
663
+ const changed = await json(
664
+ await changeRequest(harness, flowId, initial, [
665
+ { kind: 'graph.node.create', nodeId: 'other', node: { kind: 'webhook', method: 'POST', name: 'Other', bodyFields: [] } },
666
+ {
667
+ kind: 'graph.node.create',
668
+ nodeId: 'broken',
669
+ node: { kind: 'task', name: 'Broken', inputs: {}, task: { name: 'Broken', moduleId: 'missing', inputs: [], outputs: [] } },
670
+ },
671
+ { kind: 'graph.edge.connect', edge: { source: 'other', target: 'broken' } },
672
+ ]),
673
+ 200,
674
+ 'Add unfinished branch',
675
+ )
676
+ const revisionId = changedRevisionId(changed, 'Partial Draft')
677
+ const path = `/v1/flows/${flowId}/revisions/${revisionId}`
678
+ const checked = await json(
679
+ await request(harness, `${path}/check`, {
680
+ method: 'POST',
681
+ body: JSON.stringify({ engineContract, version: 1 }),
682
+ }),
683
+ 200,
684
+ 'Check entire Draft',
685
+ )
686
+ equal(checked.valid, false, 'Entire Draft remains invalid')
687
+ const run = (nodeId: string) =>
688
+ request(harness, `${path}/runs`, {
689
+ method: 'POST',
690
+ headers: { 'idempotency-key': `partial-${nodeId}` },
691
+ body: JSON.stringify({
692
+ engineContract,
693
+ inputs: {},
694
+ trigger: { nodeId, outputs: nodeId === 'start' ? {} : { headers: {}, query: {}, body: {}, webhookUrl: 'http://example.com/webhook' } },
695
+ version: 2,
696
+ }),
697
+ })
698
+ await json(await run('start'), 202, 'Run valid entry')
699
+ await error(await run('other'), 400, 'flow.invalid', 'Reject invalid entry branch')
700
+ await error(await publishRequest(harness, flowId, revisionId, null, 'partial-publish'), 400, 'flow.invalid', 'Reject incomplete publication')
701
+ },
702
+ },
703
+ {
704
+ name: 'validates, admits, lists, and cancels one Draft Run',
705
+ async verify(harness) {
706
+ const flow = await createFlow(harness, 'Run flow', 'run-flow')
707
+ const flowId = requiredString(flow.flowId, 'Run Flow flowId')
708
+ const draftRevisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Run Flow revisionId'))
709
+ const checked = await json(
710
+ await request(harness, `/v1/flows/${flowId}/revisions/${draftRevisionId}/check`, {
711
+ body: JSON.stringify({ engineContract, version: 1 }),
712
+ method: 'POST',
713
+ }),
714
+ 200,
715
+ 'Check Flow',
716
+ )
717
+ equal(checked.valid, true, 'Flow validity')
718
+ const runPath = `/v1/flows/${flowId}/revisions/${draftRevisionId}/runs`
719
+ for (const trigger of [undefined, { nodeId: 'missing', outputs: {} }, { nodeId: 'start', outputs: { payload: { unexpected: true } } }]) {
720
+ await error(
721
+ await request(harness, runPath, {
722
+ body: JSON.stringify({ engineContract, inputs: {}, trigger, version: 2 }),
723
+ headers: { 'idempotency-key': `invalid-entry-${JSON.stringify(trigger)}` },
724
+ method: 'POST',
725
+ }),
726
+ 400,
727
+ 'run.invalid',
728
+ 'Reject invalid start node or payload',
729
+ )
730
+ }
731
+ const create = () =>
732
+ request(harness, runPath, {
733
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
734
+ headers: { 'idempotency-key': 'draft-run' },
735
+ method: 'POST',
736
+ })
737
+ const run = await json(await create(), 202, 'Create Draft Run')
738
+ const runId = requiredString(run.runId, 'Draft Run runId')
739
+ equal(await json(await create(), 200, 'Replay Draft Run'), run, 'Replayed Draft Run')
740
+ await error(
741
+ await request(harness, runPath, {
742
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'another', outputs: {} }, version: 2 }),
743
+ headers: { 'idempotency-key': 'draft-run' },
744
+ method: 'POST',
745
+ }),
746
+ 409,
747
+ 'run.conflict',
748
+ 'Changing the entry conflicts with the original Run request',
749
+ )
750
+
751
+ await error(await request(harness, `/v1/runs/${runId}/result`), 409, 'run.not-terminal', 'Read queued Run result')
752
+ const secondRun = await json(
753
+ await request(harness, runPath, {
754
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
755
+ headers: { 'idempotency-key': 'draft-run-second' },
756
+ method: 'POST',
757
+ }),
758
+ 202,
759
+ 'Create second Draft Run',
760
+ )
761
+ const secondRunId = requiredString(secondRun.runId, 'Second Draft Run')
762
+ const page = await json(await request(harness, `/v1/flows/${flowId}/runs?limit=1`), 200, 'List Runs')
763
+ equal(
764
+ list(page.runs, 'Runs').map((value) => record(value, 'Run').runId),
765
+ [secondRunId],
766
+ 'Listed Runs',
767
+ )
768
+ const cursor = requiredString(page.nextCursor, 'Run cursor')
769
+ const nextPage = await json(await request(harness, `/v1/flows/${flowId}/runs?limit=1&cursor=${encodeURIComponent(cursor)}`), 200, 'List next Runs')
770
+ equal(
771
+ list(nextPage.runs, 'Next Runs').map((value) => record(value, 'Run').runId),
772
+ [runId],
773
+ 'Listed next Runs',
774
+ )
775
+ const otherFlow = await createFlow(harness, 'Other Run flow', 'other-run-flow')
776
+ const otherFlowId = requiredString(otherFlow.flowId, 'Other Run Flow flowId')
777
+ await error(
778
+ await request(harness, `/v1/flows/${otherFlowId}/runs?limit=1&cursor=${encodeURIComponent(cursor)}`),
779
+ 400,
780
+ 'page.invalid-cursor',
781
+ 'Reject Run cursor from another Flow',
782
+ )
783
+ const canceled = await json(
784
+ await request(harness, `/v1/runs/${runId}/cancel`, { body: JSON.stringify({ version: 1 }), method: 'POST' }),
785
+ 200,
786
+ 'Cancel Run',
787
+ )
788
+ equal(canceled.status, 'canceled', 'Canceled Run status')
789
+ equal((await json(await request(harness, `/v1/runs/${runId}/result`), 200, 'Read canceled Run result')).status, 'canceled', 'Canceled Run result')
790
+ equal((await json(await request(harness, `/v1/runs/${runId}/events`), 200, 'Read canceled Run events')).done, true, 'Canceled Run events')
791
+ equal(
792
+ (
793
+ await json(
794
+ await request(harness, `/v1/runs/${secondRunId}/cancel`, {
795
+ body: JSON.stringify({ version: 1 }),
796
+ method: 'POST',
797
+ }),
798
+ 200,
799
+ 'Cancel second Draft Run',
800
+ )
801
+ ).status,
802
+ 'canceled',
803
+ 'Second canceled Run status',
804
+ )
805
+ const createdAt = requiredString(run.createdAt, 'Run createdAt')
806
+ const api = client(harness)
807
+ const completeEvents = await api.getRunEvents(runId)
808
+ if (completeEvents.events.length < 2) fail('Canceled Run must expose multiple events for pagination.')
809
+ const firstEvent = await api.getRunEvents(runId, { limit: 1 })
810
+ equal(firstEvent.events, completeEvents.events.slice(0, 1), 'First event page')
811
+ const remainingEvents = await api.getRunEvents(runId, { after: firstEvent.nextAfter })
812
+ equal(remainingEvents.events, completeEvents.events.slice(1), 'Event continuation excludes cursor')
813
+ const exhausted = await api.getRunEvents(runId, { after: completeEvents.nextAfter, limit: 1 })
814
+ equal(exhausted.events, [], 'Exhausted event page')
815
+ equal(exhausted.nextAfter, completeEvents.nextAfter, 'Exhausted event cursor')
816
+ equal(exhausted.done, true, 'Terminal event page')
817
+ const createdBefore = new Date(Date.parse(createdAt) + 1).toISOString()
818
+ const filteredPage = await json(await request(harness, `/v1/flows/${flowId}/runs?limit=1&status=canceled&source=draft`), 200, 'Filter paginated Runs')
819
+ equal(
820
+ list(filteredPage.runs, 'Filtered Run page').map((value) => record(value, 'Filtered Run').runId),
821
+ [secondRunId],
822
+ 'Filtered first Run page',
823
+ )
824
+ const filteredCursor = requiredString(filteredPage.nextCursor, 'Filtered Run cursor')
825
+ const filteredNextPage = await json(
826
+ await request(harness, `/v1/flows/${flowId}/runs?limit=1&status=canceled&source=draft&cursor=${encodeURIComponent(filteredCursor)}`),
827
+ 200,
828
+ 'Continue filtered Runs',
829
+ )
830
+ equal(
831
+ list(filteredNextPage.runs, 'Next filtered Run page').map((value) => record(value, 'Filtered Run').runId),
832
+ [runId],
833
+ 'Filtered next Run page',
834
+ )
835
+ const filtered = await json(
836
+ await request(
837
+ harness,
838
+ `/v1/flows/${flowId}/runs?status=canceled&source=draft&createdFrom=${encodeURIComponent(createdAt)}&createdBefore=${encodeURIComponent(createdBefore)}&runId=${encodeURIComponent(runId)}`,
839
+ ),
840
+ 200,
841
+ 'Filter Runs',
842
+ )
843
+ equal(
844
+ list(filtered.runs, 'Filtered Runs').map((value) => record(value, 'Filtered Run').runId),
845
+ [runId],
846
+ 'Filtered Run list',
847
+ )
848
+ const excluded = await json(await request(harness, `/v1/flows/${flowId}/runs?createdBefore=${encodeURIComponent(createdAt)}`), 200, 'Filter Run boundary')
849
+ equal(list(excluded.runs, 'Boundary Runs'), [], 'Exclusive Run upper boundary')
850
+ for (const invalidQuery of [
851
+ 'source=manual',
852
+ 'source=trigger',
853
+ 'createdFrom=invalid',
854
+ 'createdFrom=2026-02-30T00%3A00%3A00Z',
855
+ `createdFrom=${encodeURIComponent(createdBefore)}&createdBefore=${encodeURIComponent(createdAt)}`,
856
+ 'runId=',
857
+ ]) {
858
+ await error(await request(harness, `/v1/flows/${flowId}/runs?${invalidQuery}`), 400, 'run.invalid', `Reject Run filter ${invalidQuery}`)
859
+ }
860
+ },
861
+ },
862
+ {
863
+ name: 'pauses, resolves, resumes, and cancels waiting Draft Runs',
864
+ runtime: true,
865
+ async verify(harness) {
866
+ const flow = await createFlow(harness, 'Wait flow', 'wait-flow')
867
+ const flowId = requiredString(flow.flowId, 'Wait Flow flowId')
868
+ const initialRevisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Wait Flow revisionId'))
869
+ const changed = await json(
870
+ await changeRequest(harness, flowId, initialRevisionId, [
871
+ {
872
+ kind: 'graph.node.create',
873
+ node: {
874
+ inputDefinitions: [{ handle: 'value', jsonSchema: {}, nullable: true, value: null }],
875
+ inputs: { value: { kind: 'value', value: { request: 1 } } },
876
+ kind: 'approval',
877
+ name: 'Approval',
878
+ prompt: 'Approve request 1?',
879
+ },
880
+ nodeId: 'approval',
881
+ },
882
+ { kind: 'graph.edge.connect', edge: { source: 'start', target: 'approval' } },
883
+ ]),
884
+ 200,
885
+ 'Create Wait',
886
+ )
887
+ const revisionId = changedRevisionId(changed, 'Wait Revision')
888
+ const runPath = `/v1/flows/${flowId}/revisions/${revisionId}/runs`
889
+ const create = async (key: string, message: string) =>
890
+ json(
891
+ await request(harness, runPath, {
892
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
893
+ headers: { 'idempotency-key': key },
894
+ method: 'POST',
895
+ }),
896
+ 202,
897
+ message,
898
+ )
899
+ const run = await create('wait-run', 'Create Wait Run')
900
+ const runId = requiredString(run.runId, 'Wait Run runId')
901
+ const awaitRun = async (initial: RecordValue, ready: (value: RecordValue) => boolean, message: string): Promise<RecordValue> => {
902
+ const id = requiredString(initial.runId, 'Run id')
903
+ const deadline = performance.now() + 10_000
904
+ let current = initial
905
+ while (!ready(current)) {
906
+ if (performance.now() >= deadline) fail(`${message}: timed out after 10000ms; last Run: ${JSON.stringify(current)}.`)
907
+ await new Promise((resolve) => setTimeout(resolve, 25))
908
+ current = await json(await request(harness, `/v1/runs/${id}`), 200, message)
909
+ }
910
+ return current
911
+ }
912
+ const hasWait = (value: RecordValue) => list(value.waits, 'Waits').length > 0
913
+ let detail = await awaitRun(run, hasWait, 'Wait Run to pause')
914
+ equal(detail.status, 'running', 'Wait Run status')
915
+ const waiting = record(list(detail.waits, 'Waits')[0], 'Active Wait')
916
+ equal(waiting.actions, ['approve', 'reject'], 'Wait actions')
917
+ equal(waiting.nodeId, 'approval', 'Wait node')
918
+ equal(waiting.prompt, 'Approve request 1?', 'Wait prompt')
919
+ requiredString(waiting.expiresAt, 'Wait expiry')
920
+ requiredString(waiting.waitingSince, 'Wait start')
921
+ const waitId = requiredString(waiting.waitId, 'Wait id')
922
+
923
+ const waitingPage = await json(await request(harness, `/v1/flows/${flowId}/runs?pendingWait=true`), 200, 'List waiting Runs')
924
+ equal(
925
+ list(waitingPage.runs, 'Waiting Runs').map((value) => record(value, 'Waiting Run').runId),
926
+ [runId],
927
+ 'Waiting Run list',
928
+ )
929
+ const waitingEvents = await json(await request(harness, `/v1/runs/${runId}/events`), 200, 'Read waiting Run events')
930
+ for (const event of list(waitingEvents.events, 'Run events')) decodeRunEvent(event)
931
+ equal(waitingEvents.done, false, 'Waiting events done')
932
+ if (!list(waitingEvents.events, 'Waiting events').some((value) => record(value, 'Waiting event').kind == 'wait.created')) {
933
+ fail('Waiting Run must contain wait.created.')
934
+ }
935
+ await error(await request(harness, `/v1/runs/${runId}/result`), 409, 'run.not-terminal', 'Read waiting Run result')
936
+
937
+ const resolvePath = `/v1/runs/${runId}/waits/${waitId}/resolve`
938
+ const resolveWait = (action: 'approve' | 'continue' | 'reject') =>
939
+ request(harness, resolvePath, { body: JSON.stringify({ action, version: 1 }), method: 'POST' })
940
+ await error(await resolveWait('continue'), 400, 'run.invalid', 'Reject unsupported Wait action')
941
+ const approved = await json(await resolveWait('approve'), 200, 'Approve Wait')
942
+ equal(approved.action, 'approve', 'Approved action')
943
+ equal(approved.resolutionAccepted, true, 'Approved resolution')
944
+ equal(approved.status, 'running', 'Resolved Run status')
945
+ requiredString(approved.resolvedAt, 'Wait resolution time')
946
+ const rejected = await json(await resolveWait('reject'), 200, 'Reject resolved Wait')
947
+ equal(rejected.action, 'approve', 'Winning action')
948
+ equal(rejected.resolutionAccepted, false, 'Competing resolution')
949
+
950
+ detail = await awaitRun(detail, (value) => value.status == 'completed', 'Wait Run to resume')
951
+ equal(detail.status, 'completed', 'Resumed Run status')
952
+ equal(detail.waits, [], 'Completed Wait projection')
953
+ const replay = await json(await resolveWait('approve'), 200, 'Replay approved Wait')
954
+ equal(replay.resolutionAccepted, true, 'Replayed resolution')
955
+ equal(replay.status, 'completed', 'Replayed terminal status')
956
+ const completedEvents = await json(await request(harness, `/v1/runs/${runId}/events`), 200, 'Read resumed Run events')
957
+ for (const event of list(completedEvents.events, 'Run events')) decodeRunEvent(event)
958
+ equal(
959
+ list(completedEvents.events, 'Completed events').filter((value) => record(value, 'Completed event').kind == 'run.resolved').length,
960
+ 1,
961
+ 'Resolved event count',
962
+ )
963
+
964
+ const canceled = await create('wait-run-cancel', 'Create cancelable Wait Run')
965
+ const canceledRunId = requiredString(canceled.runId, 'Cancelable Wait Run id')
966
+ const canceledDetail = await awaitRun(canceled, hasWait, 'Wait cancelable Run to pause')
967
+ equal(canceledDetail.status, 'running', 'Cancelable Wait status')
968
+ equal(
969
+ (
970
+ await json(
971
+ await request(harness, `/v1/runs/${canceledRunId}/cancel`, { body: JSON.stringify({ version: 1 }), method: 'POST' }),
972
+ 200,
973
+ 'Cancel waiting Run',
974
+ )
975
+ ).status,
976
+ 'canceled',
977
+ 'Canceled waiting Run status',
978
+ )
979
+ },
980
+ },
981
+ {
982
+ name: 'rejects invalid and missing Flow control targets',
983
+ async verify(harness) {
984
+ const missingFlow = '00000000-0000-7000-8000-000000000001'
985
+ const missingRevision = '00000000-0000-7000-8000-000000000002'
986
+ const missingRun = '00000000-0000-7000-8000-000000000003'
987
+ await error(await request(harness, `/v1/flows/${missingFlow}`), 404, 'flow.not-found', 'Read missing Flow')
988
+ await error(
989
+ await request(harness, `/v1/flows/${missingFlow}`, { body: JSON.stringify({ name: 'Missing', version: 1 }), method: 'PATCH' }),
990
+ 404,
991
+ 'flow.not-found',
992
+ 'Rename missing Flow',
993
+ )
994
+ await error(await request(harness, `/v1/flows/${missingFlow}`, { method: 'DELETE' }), 404, 'flow.not-found', 'Retire missing Flow')
995
+ await error(await request(harness, `/v1/flows/${missingFlow}/draft`), 404, 'flow.not-found', 'Read missing Draft')
996
+ await error(await request(harness, `/v1/flows/${missingFlow}/revisions/${missingRevision}`), 404, 'flow.not-found', 'Read missing Revision')
997
+ await error(await request(harness, `/v1/flows/${missingFlow}/presentation`), 404, 'flow.not-found', 'Read missing Presentation')
998
+ await error(
999
+ await request(harness, `/v1/flows/${missingFlow}/presentation`, {
1000
+ body: JSON.stringify({ expectedRevision: 1, value: {}, version: 1 }),
1001
+ method: 'PUT',
1002
+ }),
1003
+ 404,
1004
+ 'flow.not-found',
1005
+ 'Update missing Presentation',
1006
+ )
1007
+ await error(await request(harness, `/v1/flows/${missingFlow}/runs`), 404, 'flow.not-found', 'List missing Flow Runs')
1008
+ await error(await request(harness, `/v1/runs/${missingRun}`), 404, 'run.not-found', 'Read missing Run')
1009
+ await error(await request(harness, `/v1/runs/${missingRun}/events`), 404, 'run.not-found', 'Read missing Run events')
1010
+ await error(await request(harness, `/v1/runs/${missingRun}/result`), 404, 'run.not-found', 'Read missing Run result')
1011
+ await error(
1012
+ await request(harness, `/v1/runs/${missingRun}/cancel`, { body: JSON.stringify({ version: 1 }), method: 'POST' }),
1013
+ 404,
1014
+ 'run.not-found',
1015
+ 'Cancel missing Run',
1016
+ )
1017
+
1018
+ const flow = await createFlow(harness, 'Invalid targets', 'invalid-targets')
1019
+ const flowId = requiredString(flow.flowId, 'Invalid targets Flow')
1020
+ const revisionId = requiredString(flow.draftRevisionId, 'Invalid targets Revision')
1021
+ const check = (targetRevision: string, contract: string) =>
1022
+ request(harness, `/v1/flows/${flowId}/revisions/${targetRevision}/check`, {
1023
+ body: JSON.stringify({ engineContract: contract, version: 1 }),
1024
+ method: 'POST',
1025
+ })
1026
+ await error(await check(revisionId, 'unsupported-engine/v1'), 409, 'engine.unsupported', 'Check unsupported Engine')
1027
+ await error(await check(missingRevision, engineContract), 404, 'flow.not-found', 'Check missing Revision')
1028
+ const run = (targetRevision: string, contract: string, key: string) =>
1029
+ request(harness, `/v1/flows/${flowId}/revisions/${targetRevision}/runs`, {
1030
+ body: JSON.stringify({ engineContract: contract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
1031
+ headers: { 'idempotency-key': key },
1032
+ method: 'POST',
1033
+ })
1034
+ await error(await run(revisionId, 'unsupported-engine/v1', 'unsupported-run'), 409, 'engine.unsupported', 'Run unsupported Engine')
1035
+ await error(await run(missingRevision, engineContract, 'missing-revision-run'), 404, 'flow.not-found', 'Run missing Revision')
1036
+ },
1037
+ },
1038
+ ]
1039
+
1040
+ export const publicationControlApiConformanceCases: readonly ControlApiConformanceCase[] = [
1041
+ {
1042
+ name: 'controls Live admission independently from publishing and Draft testing',
1043
+ async verify(harness) {
1044
+ const flow = await createFlow(harness, 'Enabled flow', 'enabled-flow')
1045
+ const flowId = requiredString(flow.flowId, 'Flow id')
1046
+ const revisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Draft id'))
1047
+ const change = (enabled: boolean, expectedPublicationId: string) =>
1048
+ request(harness, `/v1/flows/${flowId}/enabled`, {
1049
+ method: 'PUT',
1050
+ body: JSON.stringify({ enabled, expectedPublicationId, version: 1 }),
1051
+ })
1052
+ await error(await change(true, 'missing'), 409, 'flow.conflict', 'Cannot enable unpublished Flow')
1053
+ const first = await completePublish(harness, await publishRequest(harness, flowId, revisionId, null, 'enable-first'), 202, 'Publish')
1054
+ const publicationId = requiredString(first.publication.publicationId, 'Publication id')
1055
+ const disabled = await json(await change(false, publicationId), 200, 'Disable')
1056
+ equal(disabled.live, { enabled: false, hasStart: true, publicationId, revisionId }, 'Disabled summary')
1057
+ const page = await json(await request(harness, '/v1/flows'), 200, 'List disabled Flow')
1058
+ equal(page.flows, [{ ...disabled, referenceCount: 0 }], 'List includes Live state')
1059
+ equal((await json(await request(harness, `/v1/flows/${flowId}/live`), 200, 'Disabled Live')).status, 'suspended', 'Disabled Live status')
1060
+ await error(await liveRunRequest(harness, publicationId, 'disabled-run'), 412, 'live.conflict', 'Disabled Live rejects Run')
1061
+ await json(
1062
+ await request(harness, `/v1/flows/${flowId}/revisions/${revisionId}/runs`, {
1063
+ method: 'POST',
1064
+ headers: { 'idempotency-key': 'disabled-draft' },
1065
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
1066
+ }),
1067
+ 202,
1068
+ 'Disabled Flow permits Draft test',
1069
+ )
1070
+ const next = await completePublish(harness, await publishRequest(harness, flowId, revisionId, publicationId, 'enable-next'), 202, 'Publish disabled Flow')
1071
+ const nextId = requiredString(next.publication.publicationId, 'Next Publication id')
1072
+ equal(
1073
+ record((await json(await request(harness, `/v1/flows/${flowId}`), 200, 'Read Flow')).live, 'Live').enabled,
1074
+ false,
1075
+ 'Publish preserves disabled state',
1076
+ )
1077
+ await error(await change(true, publicationId), 409, 'flow.conflict', 'Stale switch rejects')
1078
+ await json(await change(true, nextId), 200, 'Enable')
1079
+ await json(await liveRunRequest(harness, nextId, 'enabled-run'), 202, 'Enabled Live accepts Run')
1080
+ },
1081
+ },
1082
+
1083
+ {
1084
+ name: 'publishes one immutable Flow with idempotent Live CAS',
1085
+ async verify(harness) {
1086
+ const flow = await createFlow(harness, 'Publication flow', 'publication-flow')
1087
+ const flowId = requiredString(flow.flowId, 'Publication Flow flowId')
1088
+ const draftRevisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Publication Flow revisionId'))
1089
+ equal((await json(await request(harness, `/v1/flows/${flowId}/live`), 200, 'Read Live')).status, 'not-published', 'Initial Live status')
1090
+ const publish = () => publishRequest(harness, flowId, draftRevisionId, null, 'publication-first')
1091
+ const completed = await completePublish(harness, await publish(), 202, 'Publish Flow')
1092
+ const publication = completed.publication
1093
+ const publicationId = requiredString(publication.publicationId, 'Publication id')
1094
+ equal(await json(await publish(), 202, 'Replay Publish'), completed.operation, 'Replayed Publish operation')
1095
+ await error(await publishRequest(harness, flowId, draftRevisionId, publicationId, 'publication-first'), 409, 'publication.conflict', 'Conflicting replay')
1096
+ const live = await json(await request(harness, `/v1/flows/${flowId}/live`), 200, 'Read published Live')
1097
+ equal(live.publication, publication, 'Live Publication')
1098
+ const editor = await json(await request(harness, `/v1/flows/${flowId}/editor`), 200, 'Read published editor')
1099
+ equal(editor.live, live, 'Published editor Live projection')
1100
+ const history = await json(await request(harness, `/v1/flows/${flowId}/publications?includeTotal=true`), 200, 'List Publications')
1101
+ equal(history.publications, [publication], 'Publication history')
1102
+ equal(history.total, 1, 'Publication total')
1103
+ await error(
1104
+ await request(harness, `/v1/flows/${flowId}/revisions/${draftRevisionId}/publications`, {
1105
+ body: JSON.stringify({ engineContract: 'unsupported-engine/v1', expectedLivePublicationId: publicationId, version: 1 }),
1106
+ headers: { 'idempotency-key': 'unsupported-publication' },
1107
+ method: 'POST',
1108
+ }),
1109
+ 409,
1110
+ 'engine.unsupported',
1111
+ 'Publish unsupported Engine',
1112
+ )
1113
+ await error(
1114
+ await publishRequest(harness, flowId, '00000000-0000-7000-8000-000000000002', publicationId, 'missing-revision-publication'),
1115
+ 404,
1116
+ 'flow.not-found',
1117
+ 'Publish missing Revision',
1118
+ )
1119
+ await error(
1120
+ await rollbackRequest(harness, flowId, '00000000-0000-7000-8000-000000000004', publicationId, 'missing-rollback'),
1121
+ 404,
1122
+ 'publication.not-found',
1123
+ 'Rollback missing Publication',
1124
+ )
1125
+ await error(
1126
+ await liveRunRequest(harness, '00000000-0000-7000-8000-000000000004', 'missing-publication-run'),
1127
+ 404,
1128
+ 'publication.not-found',
1129
+ 'Run missing Publication',
1130
+ )
1131
+ },
1132
+ },
1133
+ {
1134
+ name: 'rolls back immutable history and fixes a Live Run target',
1135
+ async verify(harness) {
1136
+ const flow = await createFlow(harness, 'Rollback flow', 'rollback-flow')
1137
+ const flowId = requiredString(flow.flowId, 'Rollback Flow flowId')
1138
+ const firstRevisionId = await addStartTrigger(harness, flowId, requiredString(flow.draftRevisionId, 'Rollback Flow revisionId'))
1139
+ const layoutApi = client(harness)
1140
+ const initialLayout = await layoutApi.getPresentation(flowId)
1141
+ const publishedLayout = await json(
1142
+ await request(harness, `/v1/flows/${flowId}/presentation`, {
1143
+ method: 'PUT',
1144
+ body: JSON.stringify({ version: 1, expectedRevision: initialLayout.revision, value: { historical: 'first' } }),
1145
+ }),
1146
+ 200,
1147
+ 'Save first layout',
1148
+ )
1149
+ const first = (
1150
+ await completePublish(harness, await publishRequest(harness, flowId, firstRevisionId, null, 'publish-first'), 202, 'Publish first Revision')
1151
+ ).publication
1152
+ const firstPublicationId = requiredString(first.publicationId, 'First Publication')
1153
+ equal((await layoutApi.getPublicationPresentation(flowId, firstPublicationId)).presentation, publishedLayout, 'Publication fixes its layout')
1154
+ const currentLayout = await json(
1155
+ await request(harness, `/v1/flows/${flowId}/presentation`, {
1156
+ method: 'PUT',
1157
+ body: JSON.stringify({ version: 1, expectedRevision: publishedLayout.revision, value: { historical: 'second' } }),
1158
+ }),
1159
+ 200,
1160
+ 'Save second layout',
1161
+ )
1162
+ equal(
1163
+ (await layoutApi.getPublicationPresentation(flowId, firstPublicationId)).presentation,
1164
+ publishedLayout,
1165
+ 'Draft movement cannot change published layout',
1166
+ )
1167
+ const changed = await json(await addValueNode(harness, flowId, firstRevisionId), 200, 'Change Draft')
1168
+ const secondRevisionId = changedRevisionId(changed, 'Second Revision')
1169
+ const second = (
1170
+ await completePublish(
1171
+ harness,
1172
+ await publishRequest(harness, flowId, secondRevisionId, firstPublicationId, 'publish-second'),
1173
+ 202,
1174
+ 'Publish second Revision',
1175
+ )
1176
+ ).publication
1177
+ const secondPublicationId = requiredString(second.publicationId, 'Second Publication')
1178
+ await error(await liveRunRequest(harness, firstPublicationId, 'stale-live-run'), 412, 'live.conflict', 'Run stale Publication')
1179
+ const api = client(harness)
1180
+ const history = await api.listPublications(flowId, { limit: 1, includeTotal: true })
1181
+ equal(history.total, 2, 'Publication total across pages')
1182
+ equal(
1183
+ history.publications.map((item) => item.publicationId),
1184
+ [secondPublicationId],
1185
+ 'Newest Publication first',
1186
+ )
1187
+ const next = await api.listPublications(flowId, { limit: 1, cursor: requiredString(history.nextCursor, 'Publication cursor') })
1188
+ equal(
1189
+ next.publications.map((item) => item.publicationId),
1190
+ [firstPublicationId],
1191
+ 'Publication continuation',
1192
+ )
1193
+ equal(next.nextCursor, undefined, 'End of Publication history')
1194
+ const other = await api.createFlow('Other history', 'other-history')
1195
+ await error(
1196
+ await request(harness, `/v1/flows/${encodeURIComponent(other.flowId)}/publications?cursor=${encodeURIComponent(history.nextCursor!)}`),
1197
+ 400,
1198
+ 'page.invalid-cursor',
1199
+ 'Publication cursor belongs to one Flow',
1200
+ )
1201
+ const rollback = () => rollbackRequest(harness, flowId, firstPublicationId, secondPublicationId, 'rollback-first')
1202
+ const restored = await json(await rollback(), 201, 'Rollback Flow')
1203
+ const restoredPublicationId = requiredString(restored.publicationId, 'Rollback Publication')
1204
+ equal(restored.operation, 'rollback', 'Rollback operation')
1205
+ equal(restored.sourcePublicationId, firstPublicationId, 'Rollback source')
1206
+ equal((await api.getPublicationPresentation(flowId, restoredPublicationId)).presentation, publishedLayout, 'Rollback inherits source layout')
1207
+ equal(await api.getPresentation(flowId), currentLayout, 'Rollback does not change draft layout')
1208
+ await error(
1209
+ await request(harness, `/v1/flows/${other.flowId}/publications/${firstPublicationId}/presentation`),
1210
+ 404,
1211
+ 'publication.not-found',
1212
+ 'Snapshot belongs to one Flow',
1213
+ )
1214
+ equal(await json(await rollback(), 200, 'Replay Rollback'), restored, 'Replayed Rollback')
1215
+ const run = await json(await liveRunRequest(harness, restoredPublicationId, 'rollback-run'), 202, 'Create Live Run')
1216
+ equal(run.flowId, flowId, 'Live Run flowId')
1217
+ equal(run.revisionId, firstRevisionId, 'Live Run revisionId')
1218
+ await json(await request(harness, `/v1/flows/${flowId}`, { method: 'DELETE' }), 202, 'Retire Flow')
1219
+ await error(await liveRunRequest(harness, restoredPublicationId, 'retired-run'), 409, 'flow.busy', 'Run retired Flow')
1220
+ },
1221
+ },
1222
+ ]
1223
+
1224
+ export const triggerControlApiConformanceCases: readonly ControlApiConformanceCase[] = [
1225
+ {
1226
+ name: 'exposes one deployment-scoped Trigger Key catalog',
1227
+ async verify(harness) {
1228
+ const keys = await json(await request(harness, '/v1/trigger-keys'), 200, 'List Trigger Keys')
1229
+ const catalog = await json(await request(harness, '/v1/trigger-keys/catalog'), 200, 'List Trigger definitions')
1230
+ const api = client(harness)
1231
+ await api.listTriggerKeys()
1232
+ const decoded = (await api.getTriggerCatalog('en')).data
1233
+ equal(decoded.definitions, catalog.definitions, 'Decoded Trigger definitions')
1234
+ const summaries = list(keys.keys, 'Trigger Keys').map((value) => record(value, 'Trigger Key'))
1235
+ const definitions = list(catalog.definitions, 'Trigger definitions').map((value) => record(value, 'Trigger definition'))
1236
+ equal(
1237
+ summaries.map((value) => value.key),
1238
+ definitions.map((value) => value.key),
1239
+ 'Trigger catalog identities',
1240
+ )
1241
+ for (const [index, summary] of summaries.entries()) {
1242
+ const key = requiredString(summary.key, 'Trigger Key')
1243
+ equal(
1244
+ (await json(await request(harness, `/v1/trigger-keys/${encodeURIComponent(key)}`), 200, 'Read Trigger Key')).definition,
1245
+ definitions[index],
1246
+ 'Trigger detail',
1247
+ )
1248
+ }
1249
+ for (const route of ['/v1/trigger-keys', '/v1/trigger-keys/catalog']) {
1250
+ for (const [locale, expected] of [
1251
+ ['en', 'en'],
1252
+ ['zh-Hant-HK', 'zh-TW'],
1253
+ ['de', 'en'],
1254
+ ]) {
1255
+ await revalidate(harness, `${route}?locale=${locale}`, expected)
1256
+ const response = await request(harness, `${route}?locale=${locale}`, { headers: { 'accept-language': 'ja' } })
1257
+ equal(response.headers.get('content-language'), expected, 'Explicit locale precedes language header')
1258
+ }
1259
+ const negotiated = await request(harness, route, { headers: { 'accept-language': 'ja' } })
1260
+ equal(negotiated.headers.get('content-language'), 'ja', 'Negotiated Trigger language')
1261
+ await error(await request(harness, `${route}?locale=bad_tag`), 400, 'flow.invalid', 'Invalid Trigger locale')
1262
+ }
1263
+ const english = await api.getTriggerCatalog('en')
1264
+ const translated = await api.getTriggerCatalog('zh-CN', english)
1265
+ equal(translated.data.locale, 'zh-CN', 'Localized catalog')
1266
+ equal(translated.data.definitions, english.data.definitions, 'Canonical definitions survive localization')
1267
+ if (translated.etag == english.etag) fail('Different catalog locales must not share an ETag.')
1268
+ await error(await request(harness, '/v1/trigger-keys/conformance.missing'), 404, 'trigger-key.not-found', 'Missing Trigger Key')
1269
+ },
1270
+ },
1271
+ {
1272
+ name: 'operates and retires one Live Trigger binding',
1273
+ async verify(harness) {
1274
+ const flow = await createFlow(harness, 'Trigger flow', 'trigger-flow')
1275
+ const flowId = requiredString(flow.flowId, 'Trigger Flow flowId')
1276
+ const firstRevisionId = requiredString(flow.draftRevisionId, 'Trigger Flow revisionId')
1277
+ const changed = await json(
1278
+ await changeRequest(harness, flowId, firstRevisionId, [
1279
+ {
1280
+ kind: 'graph.node.create',
1281
+ node: { cronTimes: [{ type: 'every', unit: 'hour', value: 1 }], kind: 'cron', name: 'Scheduled' },
1282
+ nodeId: 'cron',
1283
+ },
1284
+ {
1285
+ kind: 'graph.node.create',
1286
+ node: { bodyFields: [], kind: 'webhook', method: 'POST', name: 'Incoming' },
1287
+ nodeId: 'webhook',
1288
+ },
1289
+ ]),
1290
+ 200,
1291
+ 'Create Trigger nodes',
1292
+ )
1293
+ const triggerRevisionId = changedRevisionId(changed, 'Trigger Revision')
1294
+ const publication = (
1295
+ await completePublish(harness, await publishRequest(harness, flowId, triggerRevisionId, null, 'trigger-publication'), 202, 'Publish Trigger Flow')
1296
+ ).publication
1297
+ const publicationId = requiredString(publication.publicationId, 'Trigger Publication')
1298
+ const base = `/v1/flows/${flowId}/triggers`
1299
+ const bindings = list((await json(await request(harness, base), 200, 'List Trigger bindings')).bindings, 'Trigger bindings').map((value) =>
1300
+ record(value, 'Trigger binding'),
1301
+ )
1302
+ equal(
1303
+ bindings.map((value) => value.triggerNodeId),
1304
+ ['cron', 'webhook'],
1305
+ 'Trigger binding order',
1306
+ )
1307
+ for (const binding of bindings) {
1308
+ equal(binding.currentPublicationId, publicationId, 'Trigger Publication')
1309
+ equal(binding.operatorState, undefined, 'No individual Trigger control state')
1310
+ }
1311
+ const online = await client(harness).getLiveDetails(flowId)
1312
+ equal(online.publication?.publicationId, publicationId, 'Online Publication')
1313
+ equal(
1314
+ online.entries.map((entry) => entry.type),
1315
+ ['cron', 'webhook'],
1316
+ 'Online entries',
1317
+ )
1318
+ const onlineWebhook = online.entries.find((entry) => entry.type == 'webhook')
1319
+ if (onlineWebhook?.type != 'webhook') fail('Missing online Webhook.')
1320
+ equal(onlineWebhook.config.method, 'POST', 'Online request method')
1321
+ equal(onlineWebhook.config.body, {}, 'Online request fields')
1322
+ const webhookPath = `${base}/webhook`
1323
+ const endpointUrl = requiredString(
1324
+ record((await json(await request(harness, webhookPath), 200, 'Read Webhook')).binding, 'Webhook').endpointUrl,
1325
+ 'Webhook endpoint',
1326
+ )
1327
+ for (const action of ['pause', 'resume']) {
1328
+ const response = await request(harness, `${webhookPath}/${action}`, { body: JSON.stringify({ version: 1 }), method: 'POST' })
1329
+ equal(response.status, 404, 'Individual Trigger control is unavailable')
1330
+ }
1331
+ equal(onlineWebhook.binding.endpointUrl, endpointUrl, 'Online endpoint shares binding authority')
1332
+ const enable = (enabled: boolean) =>
1333
+ request(harness, `/v1/flows/${flowId}/enabled`, {
1334
+ method: 'PUT',
1335
+ body: JSON.stringify({ enabled, expectedPublicationId: publicationId, version: 1 }),
1336
+ })
1337
+ await json(await enable(false), 200, 'Disable Trigger Flow')
1338
+ equal((await harness.request(new Request(endpointUrl, { method: 'POST' }))).status, 404, 'Disabled Flow blocks callback')
1339
+ await json(await enable(true), 200, 'Enable Trigger Flow')
1340
+ equal((await harness.request(new Request(endpointUrl, { method: 'POST' }))).status, 200, 'Enabled Flow permits callback')
1341
+
1342
+ const api = client(harness)
1343
+ equal((await api.listFlowTriggerActivities(flowId, 'webhook')).activities, [], 'Flow toggles do not record individual Trigger operations')
1344
+
1345
+ const removed = await json(
1346
+ await changeRequest(harness, flowId, triggerRevisionId, [{ kind: 'graph.node.delete', nodeId: 'webhook' }]),
1347
+ 200,
1348
+ 'Delete Webhook',
1349
+ )
1350
+ await completePublish(
1351
+ harness,
1352
+ await publishRequest(harness, flowId, changedRevisionId(removed, 'Retired Trigger Revision'), publicationId, 'retire-trigger'),
1353
+ 202,
1354
+ 'Publish retired Trigger',
1355
+ )
1356
+ equal(
1357
+ record((await json(await request(harness, webhookPath), 200, 'Read retired Webhook')).binding, 'Retired Webhook').currentPublicationId,
1358
+ undefined,
1359
+ 'Retired Publication',
1360
+ )
1361
+ },
1362
+ },
1363
+ ]
1364
+
1365
+ export const connectorControlApiConformanceCases: readonly ControlApiConformanceCase[] = [
1366
+ {
1367
+ name: 'projects implicit Connector access without writable binding state',
1368
+ async verify(harness) {
1369
+ const created = await createFlow(harness, 'Connector access', 'connector-access-create')
1370
+ const flowId = requiredString(created.flowId, 'Flow ID')
1371
+ const access = await json(await request(harness, `/v1/flows/${flowId}/connector-access`), 200, 'Read Connector access')
1372
+ equal(access.accessRevision, 0, 'Implicit Connector access revision')
1373
+ equal(access.bindings, [], 'Implicit Connector access bindings')
1374
+ equal(access.mode, 'implicit', 'Implicit Connector access mode')
1375
+ if (!requiredString(access.sharedAccessDigest, 'Implicit Connector access digest').startsWith('implicit')) {
1376
+ fail('Implicit Connector access digest must identify the implicit authority generation.')
1377
+ }
1378
+ equal(access.version, 1, 'Implicit Connector access version')
1379
+ equal(
1380
+ await json(
1381
+ await request(harness, `/v1/flows/${flowId}/connector-access/candidates/query`, {
1382
+ method: 'POST',
1383
+ body: JSON.stringify({ providerIds: ['mail', 'github'], version: 1 }),
1384
+ }),
1385
+ 200,
1386
+ 'Read Connector access candidates',
1387
+ ),
1388
+ { results: ['mail', 'github'].map((providerId) => ({ candidates: [], mode: 'implicit', providerId, version: 1 })), version: 1 },
1389
+ 'Implicit Connector access candidates',
1390
+ )
1391
+ for (const providerIds of [[], ['mail', 'mail'], [''], ['x'.repeat(257)], [1]]) {
1392
+ await error(
1393
+ await request(harness, `/v1/flows/${flowId}/connector-access/candidates/query`, {
1394
+ method: 'POST',
1395
+ body: JSON.stringify({ providerIds, version: 1 }),
1396
+ }),
1397
+ 409,
1398
+ 'connector.access-invalid',
1399
+ 'Invalid candidate provider query',
1400
+ )
1401
+ }
1402
+ for (const method of ['PUT', 'DELETE']) {
1403
+ await error(
1404
+ await request(harness, `/v1/flows/${flowId}/connector-access/mail/service`, {
1405
+ method,
1406
+ body: JSON.stringify({ expectedAccessRevision: 0, version: 1 }),
1407
+ }),
1408
+ 409,
1409
+ 'connector.access-unsupported',
1410
+ 'Service selection requires selectable access',
1411
+ )
1412
+ }
1413
+ await error(
1414
+ await request(harness, `/v1/flows/${flowId}/connector-access/mail`, {
1415
+ body: JSON.stringify({ accessBindingId: 'editors', expectedAccessRevision: 0, version: 1 }),
1416
+ method: 'PUT',
1417
+ }),
1418
+ 409,
1419
+ 'connector.access-unsupported',
1420
+ 'Set implicit Connector access',
1421
+ )
1422
+ await error(
1423
+ await request(harness, `/v1/flows/${flowId}/connector-access/mail`, {
1424
+ body: JSON.stringify({ accessBindingId: 'editors', expectedAccessRevision: 0, version: 1 }),
1425
+ method: 'DELETE',
1426
+ }),
1427
+ 409,
1428
+ 'connector.access-unsupported',
1429
+ 'Clear implicit Connector access',
1430
+ )
1431
+ },
1432
+ },
1433
+ {
1434
+ name: 'projects the deployment Connector catalog and authorized Connections',
1435
+ async verify(harness) {
1436
+ const api = client(harness)
1437
+ const created = await api.createFlow('Connector discovery', 'connector-discovery')
1438
+ for (const flowId of [undefined, created.flowId]) {
1439
+ const providers = await api.listConnectorProviders(undefined, flowId)
1440
+ const actions = await api.listConnectorActions(undefined, undefined, flowId)
1441
+ if (providers.length == 0 || actions.length == 0) fail('Connector conformance deployment must expose a Provider and Action in both scopes.')
1442
+ const action = actions[0]!
1443
+ if (!providers.some((provider) => provider.serviceId == action.serviceId)) fail('Connector Action must refer to a listed Provider.')
1444
+ equal(await api.getConnectorAction(action.actionId, undefined, flowId), action, 'Connector Action detail')
1445
+ const filtered = await api.listConnectorActions(action.serviceId, undefined, flowId)
1446
+ equal(
1447
+ filtered,
1448
+ actions.filter((item) => item.serviceId == action.serviceId),
1449
+ 'Connector service filter',
1450
+ )
1451
+ const searched = await api.searchConnectorActions(action.name, undefined, flowId)
1452
+ if (!searched.some((item) => item.actionId == action.actionId)) fail('Connector search must find the named Action.')
1453
+ const all = await api.listAllConnectorConnections(undefined, flowId)
1454
+ const connections = await api.listConnectorConnections(action.serviceId, undefined, flowId)
1455
+ equal(
1456
+ connections,
1457
+ all.filter((item) => item.serviceId == action.serviceId),
1458
+ 'Scoped Connection views',
1459
+ )
1460
+ await api.createConnectorConnectionPage(action.serviceId, flowId)
1461
+ const scope = flowId == null ? '' : `flowId=${encodeURIComponent(flowId)}&`
1462
+ for (const path of [
1463
+ `providers?${scope}locale=en`,
1464
+ `actions?${scope}locale=en`,
1465
+ `actions?${scope}service=${encodeURIComponent(action.serviceId)}&locale=en`,
1466
+ `actions?${scope}q=${encodeURIComponent(action.name)}&locale=en`,
1467
+ `actions/${encodeURIComponent(action.actionId)}?${scope}locale=en`,
1468
+ `connections?${scope.slice(0, -1)}`,
1469
+ `connections/${encodeURIComponent(action.serviceId)}?${scope.slice(0, -1)}`,
1470
+ ])
1471
+ await revalidate(harness, `/v1/connector/${path}`, path.startsWith('providers') ? 'en' : undefined)
1472
+ }
1473
+ for (const path of ['providers', 'actions', 'actions/conformance.missing', 'connections', 'connections/mail']) {
1474
+ await error(await request(harness, `/v1/connector/${path}?flowId=conformance.missing`), 404, 'flow.not-found', 'Missing Connector scope')
1475
+ }
1476
+ await error(
1477
+ await request(harness, `/v1/connector/connections/mail/page?flowId=${encodeURIComponent(created.flowId)}&teamId=team`, {
1478
+ method: 'POST',
1479
+ body: JSON.stringify({ version: 1 }),
1480
+ }),
1481
+ 400,
1482
+ 'flow.invalid',
1483
+ 'Conflicting Connection page scopes',
1484
+ )
1485
+ },
1486
+ },
1487
+ {
1488
+ name: 'lists all authorized Connector Connections with optional Flow scope',
1489
+ async verify(harness) {
1490
+ const flow = await createFlow(harness, 'Connector Connections', 'connector-connections-create')
1491
+ const flowId = requiredString(flow.flowId, 'Connector Connections Flow id')
1492
+ for (const scope of [undefined, flowId]) {
1493
+ const query = allConnectorConnectionsQuery(scope)
1494
+ query.decode(await json(await request(harness, query.path), 200, `List all Connector Connections (${scope ?? 'unscoped'})`))
1495
+ }
1496
+ await error(
1497
+ await request(harness, '/v1/connector/connections?flowId=conformance.missing'),
1498
+ 404,
1499
+ 'flow.not-found',
1500
+ 'List all Connector Connections for missing Flow',
1501
+ )
1502
+ },
1503
+ },
1504
+ {
1505
+ name: 'rejects invalid Connector discovery requests',
1506
+ async verify(harness) {
1507
+ await error(await request(harness, '/v1/connector/actions?service=mail&q=send'), 400, 'flow.invalid', 'Conflicting Connector query')
1508
+ await error(await request(harness, '/v1/connector/actions?q=%20%20'), 400, 'flow.invalid', 'Empty Connector query')
1509
+ await error(await request(harness, `/v1/connector/connections/${'a'.repeat(257)}`), 400, 'flow.invalid', 'Oversized Connector service')
1510
+ await error(await request(harness, '/v1/connector/actions/conformance.missing'), 404, 'connector.action-not-found', 'Missing Connector Action')
1511
+ },
1512
+ },
1513
+ ]
1514
+
1515
+ export function selectableConnectorAccessControlApiConformanceCases(
1516
+ fixture: ProviderAccessIdentity & {
1517
+ readonly accessBindingId: string
1518
+ readonly connectionDisplayName: string
1519
+ readonly permissionGroupName: string | null
1520
+ readonly providerId: string
1521
+ },
1522
+ ): readonly ControlApiConformanceCase[] {
1523
+ return [
1524
+ {
1525
+ name: 'selects services independently and removes their access bindings atomically',
1526
+ async verify(harness) {
1527
+ const api = client(harness)
1528
+ const flow = await api.createFlow('Service selection', 'service-selection')
1529
+ const initial = await api.getConnectorAccess(flow.flowId)
1530
+ const added = await api.setConnectorService(flow.flowId, fixture.providerId, true, initial.accessRevision)
1531
+ equal(added.accessRevision, initial.accessRevision + 1, 'Service selection revision')
1532
+ equal(added.providerIds, [fixture.providerId], 'Selected services')
1533
+ equal(added.bindings, [], 'Adding a service grants no account access')
1534
+ equal(added.sharedAccessDigest, initial.sharedAccessDigest, 'Service-only edits preserve authority digest')
1535
+ equal(await api.getConnectorAccess(flow.flowId), added, 'Service selection is persisted')
1536
+ const selected = await api.addProviderAccessBinding(flow.flowId, fixture.providerId, fixture.accessBindingId, added.accessRevision)
1537
+ const path = `/v1/flows/${encodeURIComponent(flow.flowId)}/connector-access/${encodeURIComponent(fixture.providerId)}/service`
1538
+ for (const method of ['PUT', 'DELETE']) {
1539
+ await error(
1540
+ await request(harness, path, { method, body: JSON.stringify({ version: 1, expectedAccessRevision: initial.accessRevision }) }),
1541
+ 412,
1542
+ 'connector.access-conflict',
1543
+ 'Stale service selection',
1544
+ )
1545
+ }
1546
+ equal(await api.getConnectorAccess(flow.flowId), selected, 'Stale edits preserve service access')
1547
+ const removed = await api.setConnectorService(flow.flowId, fixture.providerId, false, selected.accessRevision)
1548
+ equal(removed.accessRevision, selected.accessRevision + 1, 'Service removal revision')
1549
+ equal(removed.providerIds, [], 'Service removed')
1550
+ equal(removed.bindings, [], 'Removing service revokes all its bindings')
1551
+ if (removed.sharedAccessDigest == selected.sharedAccessDigest) fail('Removing bound access must change the authority digest.')
1552
+ equal(await api.getConnectorAccess(flow.flowId), removed, 'Removed service remains removed')
1553
+ },
1554
+ },
1555
+ {
1556
+ name: 'selects Provider access with optimistic concurrency',
1557
+ async verify(harness) {
1558
+ const created = await createFlow(harness, 'Selectable Connector access', 'selectable-connector-access-create')
1559
+ const flowId = requiredString(created.flowId, 'Flow ID')
1560
+ const path = `/v1/flows/${flowId}/connector-access/${encodeURIComponent(fixture.providerId)}`
1561
+ const initial = await json(await request(harness, `/v1/flows/${flowId}/connector-access`), 200, 'Read selectable Connector access')
1562
+ equal(initial.mode, 'selectable', 'Selectable Connector access mode')
1563
+ equal(initial.accessRevision, 0, 'Initial selectable Connector access revision')
1564
+ const batch = await json(
1565
+ await request(harness, `/v1/flows/${flowId}/connector-access/candidates/query`, {
1566
+ method: 'POST',
1567
+ body: JSON.stringify({ providerIds: [fixture.providerId], version: 1 }),
1568
+ }),
1569
+ 200,
1570
+ 'Read Provider access candidates',
1571
+ )
1572
+ const candidates = record(list(batch.results, 'Provider candidate results')[0], 'Provider candidates')
1573
+ equal(candidates.mode, 'selectable', 'Provider access candidate mode')
1574
+ equal(candidates.providerId, fixture.providerId, 'Provider access candidate Provider')
1575
+ const candidate = list(candidates.candidates, 'Provider access candidates').find(
1576
+ (value) => record(value, 'Provider access candidate').accessBindingId == fixture.accessBindingId,
1577
+ )
1578
+ if (candidate == null) fail('Expected selectable Provider access candidate was not returned.')
1579
+ equal(record(candidate, 'Provider access candidate').connectionId, fixture.connectionId, 'Provider access candidate Connection ID')
1580
+ equal(record(candidate, 'Provider access candidate').source, fixture.source, 'Provider access candidate source')
1581
+ equal(
1582
+ record(candidate, 'Provider access candidate').connectionDisplayName,
1583
+ fixture.connectionDisplayName,
1584
+ 'Provider access candidate connection display name',
1585
+ )
1586
+ equal(
1587
+ record(candidate, 'Provider access candidate').permissionGroupName,
1588
+ fixture.permissionGroupName,
1589
+ 'Provider access candidate permission group name',
1590
+ )
1591
+
1592
+ const selected = await json(
1593
+ await request(harness, path, {
1594
+ body: JSON.stringify({ accessBindingId: fixture.accessBindingId, expectedAccessRevision: 0, version: 1 }),
1595
+ method: 'PUT',
1596
+ }),
1597
+ 200,
1598
+ 'Select Provider access',
1599
+ )
1600
+ equal(selected.accessRevision, 1, 'Selected Connector access revision')
1601
+ equal(selected.mode, 'selectable', 'Selected Connector access mode')
1602
+ const binding = list(selected.bindings, 'Selected Connector access bindings').find(
1603
+ (value) => record(value, 'Selected Connector access binding').providerId == fixture.providerId,
1604
+ )
1605
+ if (binding == null) fail('Selected Provider access binding was not projected.')
1606
+ equal(record(binding, 'Selected Connector access binding').connectionId, fixture.connectionId, 'Selected Connector access binding Connection ID')
1607
+ equal(record(binding, 'Selected Connector access binding').source, fixture.source, 'Selected Connector access binding source')
1608
+ equal(record(binding, 'Selected Connector access binding').accessBindingId, fixture.accessBindingId, 'Selected Provider access binding ID')
1609
+ equal(
1610
+ record(binding, 'Selected Connector access binding').connectionDisplayName,
1611
+ fixture.connectionDisplayName,
1612
+ 'Selected Provider access binding connection display name',
1613
+ )
1614
+ equal(
1615
+ record(binding, 'Selected Connector access binding').permissionGroupName,
1616
+ fixture.permissionGroupName,
1617
+ 'Selected Provider access binding permission group name',
1618
+ )
1619
+ const selectedDigest = requiredString(selected.sharedAccessDigest, 'Selected Connector access digest')
1620
+
1621
+ const revisionId = await addStartTrigger(harness, flowId, requiredString(created.draftRevisionId, 'Draft Revision ID'))
1622
+ const draftRun = await json(
1623
+ await request(harness, `/v1/flows/${flowId}/revisions/${revisionId}/runs`, {
1624
+ body: JSON.stringify({ engineContract, inputs: {}, trigger: { nodeId: 'start', outputs: {} }, version: 2 }),
1625
+ headers: { 'idempotency-key': 'selectable-connector-access-draft-run' },
1626
+ method: 'POST',
1627
+ }),
1628
+ 202,
1629
+ 'Admit Draft Run with selected Provider access',
1630
+ )
1631
+ equal(draftRun.sharedAccessDigest, selectedDigest, 'Draft Run Provider access snapshot')
1632
+
1633
+ const published = await completePublish(
1634
+ harness,
1635
+ await publishRequest(harness, flowId, revisionId, null, 'selectable-connector-access-publish'),
1636
+ 202,
1637
+ 'Publish selected Provider access',
1638
+ )
1639
+ equal(published.publication.sharedAccessDigest, selectedDigest, 'Publication Provider access snapshot')
1640
+
1641
+ await error(
1642
+ await request(harness, path, {
1643
+ body: JSON.stringify({ accessBindingId: fixture.accessBindingId, expectedAccessRevision: 0, version: 1 }),
1644
+ method: 'PUT',
1645
+ }),
1646
+ 412,
1647
+ 'connector.access-conflict',
1648
+ 'Overwrite Provider access with a stale revision',
1649
+ )
1650
+
1651
+ const cleared = await json(
1652
+ await request(harness, path, {
1653
+ body: JSON.stringify({ accessBindingId: fixture.accessBindingId, expectedAccessRevision: 1, version: 1 }),
1654
+ method: 'DELETE',
1655
+ }),
1656
+ 200,
1657
+ 'Clear Provider access',
1658
+ )
1659
+ equal(cleared.accessRevision, 2, 'Cleared Connector access revision')
1660
+ equal(cleared.bindings, [], 'Cleared Connector access bindings')
1661
+ const live = await json(await request(harness, `/v1/flows/${flowId}/live`), 200, 'Read Live after clearing Provider access')
1662
+ equal(live.hasUnpublishedChanges, true, 'Cleared Draft access marks Live as unpublished')
1663
+ const publicationId = requiredString(published.publication.publicationId, 'Publication ID')
1664
+ const liveRun = await json(
1665
+ await liveRunRequest(harness, publicationId, 'selectable-connector-access-live-run'),
1666
+ 202,
1667
+ 'Admit Live Run after clearing Draft access',
1668
+ )
1669
+ equal(liveRun.sharedAccessDigest, selectedDigest, 'Live Run preserves Publication Provider access snapshot')
1670
+ },
1671
+ },
1672
+ ]
1673
+ }
1674
+
1675
+ export const controlRecoveryConformanceCases: readonly {
1676
+ readonly name: string
1677
+ verify(harness: ControlApiConformanceHarness & { restart(): Promise<void> }): Promise<void>
1678
+ }[] = [
1679
+ {
1680
+ name: 'preserves committed mutations and idempotency receipts after restart',
1681
+ async verify(harness) {
1682
+ const created = await createFlow(harness, 'Recovery', 'recovery-create')
1683
+ const flowId = requiredString(created.flowId, 'Flow ID')
1684
+ const revisionId = requiredString(created.draftRevisionId, 'Revision ID')
1685
+ const changed = await json(await addValueNode(harness, flowId, revisionId, 'marker', 'recovery-change'), 200, 'Commit change')
1686
+ await harness.restart()
1687
+ equal(
1688
+ (await json(await createFlowRequest(harness, 'Recovery', 'recovery-create'), 200, 'Replay creation after restart')).flowId,
1689
+ flowId,
1690
+ 'Durable creation identity',
1691
+ )
1692
+ equal(
1693
+ await json(await addValueNode(harness, flowId, revisionId, 'marker', 'recovery-change'), 200, 'Replay change after restart'),
1694
+ changed,
1695
+ 'Durable change receipt',
1696
+ )
1697
+ const draft = await json(await request(harness, `/v1/flows/${flowId}/draft`), 200, 'Recovered Draft')
1698
+ equal(draft.revisionId, record(changed.revision, 'Revision').revisionId, 'Recovered head')
1699
+ },
1700
+ },
1701
+ ]
1702
+
1703
+ export function eventSourceControlApiConformanceCases(fixture: {
1704
+ readonly connection: ConnectorConnection
1705
+ readonly teamId: string | null
1706
+ readonly connectionPageUrl: string
1707
+ }): readonly ControlApiConformanceCase[] {
1708
+ return [
1709
+ {
1710
+ name: 'manages event sources with scoped Connections, redacted credentials and revision conflicts',
1711
+ async verify(harness) {
1712
+ const api = client(harness)
1713
+ const connections = await api.listEventSourceConnections(fixture.teamId)
1714
+ if (!connections.some((item) => item.connectionId == fixture.connection.connectionId)) fail('Event source fixture Connection is missing.')
1715
+ const input: CreateEventSource = {
1716
+ version: 1,
1717
+ name: 'Conformance event source',
1718
+ connectionId: fixture.connection.connectionId,
1719
+ teamId: fixture.teamId,
1720
+ verificationToken: 'conformance-verification-secret',
1721
+ encryptKey: 'conformance-encryption-secret',
1722
+ eventTypes: ['im.message.receive_v1'],
1723
+ manageSubscriptions: false,
1724
+ }
1725
+ const source = await api.createEventSource(input)
1726
+ equal(source.appId, fixture.connection.providerAccountId, 'Verified application identity')
1727
+ equal(source.teamId, fixture.teamId, 'Event source Team')
1728
+ equal(source.connectionId, input.connectionId, 'Event source Connection')
1729
+ equal(source.verificationTokenConfigured, true, 'Verification token configured')
1730
+ equal(source.encryptKeyConfigured, true, 'Encryption key configured')
1731
+ const listed = await api.listEventSources()
1732
+ equal(
1733
+ listed.sources.find((item) => item.sourceId == source.sourceId),
1734
+ source,
1735
+ 'Created event source is listed',
1736
+ )
1737
+ if (JSON.stringify(listed).includes(input.verificationToken) || JSON.stringify(listed).includes(input.encryptKey))
1738
+ fail('Event source credentials leaked.')
1739
+ const flow = await api.createFlow('Event source scope', 'event-source-scope')
1740
+ const scoped = await api.listEventSources(flow.flowId)
1741
+ if (!Object.hasOwn(scoped, 'teamId')) fail('Flow-scoped event sources must return their Team identity.')
1742
+ if (scoped.sources.some((item) => item.teamId != scoped.teamId)) fail('Event sources crossed Flow Team scope.')
1743
+ equal(
1744
+ scoped.sources.some((item) => item.sourceId == source.sourceId),
1745
+ scoped.teamId == fixture.teamId,
1746
+ 'Event source visibility follows Flow Team',
1747
+ )
1748
+ await error(await request(harness, '/v1/event-sources?flowId=conformance.missing'), 404, 'flow.not-found', 'Missing event source Flow')
1749
+ const path = `/v1/event-sources/${encodeURIComponent(source.sourceId)}`
1750
+ const update = { version: 1 as const, expectedRevision: source.revision, name: 'Renamed source', enabled: false, eventTypes: ['approval_instance'] }
1751
+ const updated = await api.updateEventSource(source.sourceId, update)
1752
+ equal(updated.revision, source.revision + 1, 'Event source revision advances')
1753
+ equal(updated.name, update.name, 'Event source rename')
1754
+ equal(updated.enabled, false, 'Event source disabled')
1755
+ equal(updated.eventTypes, update.eventTypes, 'Event source filters')
1756
+ for (const method of ['PUT', 'DELETE']) {
1757
+ await error(
1758
+ await request(harness, path, { method, body: JSON.stringify(method == 'PUT' ? update : { version: 1, expectedRevision: source.revision }) }),
1759
+ 409,
1760
+ 'event-source.conflict',
1761
+ 'Stale event source revision',
1762
+ )
1763
+ }
1764
+ equal(
1765
+ (await api.listEventSources()).sources.find((item) => item.sourceId == source.sourceId),
1766
+ updated,
1767
+ 'Conflicts preserve source',
1768
+ )
1769
+ await error(
1770
+ await request(harness, '/v1/event-sources', { method: 'POST', body: JSON.stringify({ ...input, appId: 'cli_forged' }) }),
1771
+ 400,
1772
+ 'event-source.invalid',
1773
+ 'Reject client-supplied application identity',
1774
+ )
1775
+ await error(
1776
+ await request(harness, path, { method: 'PUT', body: JSON.stringify({ ...update, expectedRevision: updated.revision, eventTypes: [] }) }),
1777
+ 400,
1778
+ 'event-source.invalid',
1779
+ 'Reject empty event filters',
1780
+ )
1781
+ await api.deleteEventSource(source.sourceId, updated.revision)
1782
+ if ((await api.listEventSources()).sources.some((item) => item.sourceId == source.sourceId)) fail('Deleted event source remains listed.')
1783
+ for (const method of ['PUT', 'DELETE']) {
1784
+ await error(
1785
+ await request(harness, path, { method, body: JSON.stringify(method == 'PUT' ? update : { version: 1, expectedRevision: updated.revision }) }),
1786
+ 404,
1787
+ 'event-source.not-found',
1788
+ 'Missing event source',
1789
+ )
1790
+ }
1791
+ equal(
1792
+ await api.createConnectorConnectionPage(fixture.connection.serviceId, undefined, fixture.teamId ?? undefined),
1793
+ fixture.connectionPageUrl,
1794
+ 'Connection page uses the selected Team',
1795
+ )
1796
+ },
1797
+ },
1798
+ ]
1799
+ }
1800
+
1801
+ export function runResultControlApiConformanceCases(fixture: {
1802
+ readonly runId: string
1803
+ readonly otherRunId: string
1804
+ readonly results: readonly ResultInfo[]
1805
+ readonly resultId: string
1806
+ readonly value: { readonly rows: readonly number[] }
1807
+ }): readonly ControlApiConformanceCase[] {
1808
+ return [
1809
+ {
1810
+ name: 'pages and downloads stored tool results within their owning Run',
1811
+ async verify(harness) {
1812
+ if (fixture.results.length != 51 || fixture.value.rows.length < 2) fail('Result fixture needs 51 results and at least two rows.')
1813
+ const api = client(harness)
1814
+ const expected = fixture.results.toSorted((a, b) => (a.resultId < b.resultId ? -1 : a.resultId > b.resultId ? 1 : 0))
1815
+ const first = await api.listRunResults(fixture.runId)
1816
+ equal(first.runId, fixture.runId, 'Result list Run')
1817
+ equal(first.results, expected.slice(0, 50), 'Result page order and size')
1818
+ const after = requiredString(first.nextAfter, 'Result continuation')
1819
+ equal(after, expected[49]!.resultId, 'Result cursor')
1820
+ const second = await api.listRunResults(fixture.runId, after)
1821
+ equal(second.results, expected.slice(50), 'Result continuation excludes cursor')
1822
+ equal(second.nextAfter, undefined, 'Result end of list')
1823
+ equal((await api.listRunResults(fixture.otherRunId)).results, [], 'Other Run has no results')
1824
+ const full = await api.readRunResult(fixture.runId, fixture.resultId)
1825
+ equal(full.runId, fixture.runId, 'Result read Run')
1826
+ equal(
1827
+ full.result,
1828
+ expected.find((item) => item.resultId == fixture.resultId),
1829
+ 'Result metadata',
1830
+ )
1831
+ equal(full.page.value, fixture.value, 'Complete result')
1832
+ const page = await api.readRunResult(fixture.runId, fixture.resultId, { pointer: '/rows', limit: 1, maxBytes: 1000 })
1833
+ equal(
1834
+ page.page.entries?.map((entry) => entry.value),
1835
+ fixture.value.rows.slice(0, 1),
1836
+ 'Result member page',
1837
+ )
1838
+ equal(page.page.nextOffset, 1, 'Result member continuation')
1839
+ const next = await api.readRunResult(fixture.runId, fixture.resultId, { pointer: '/rows', offset: 1, limit: 1, maxBytes: 1000 })
1840
+ equal(
1841
+ next.page.entries?.map((entry) => entry.value),
1842
+ fixture.value.rows.slice(1, 2),
1843
+ 'Next result member',
1844
+ )
1845
+ if (new TextEncoder().encode(JSON.stringify(page.page)).byteLength > 1000) fail('Result page exceeds byte budget.')
1846
+ const path = `/v1/runs/${encodeURIComponent(fixture.runId)}/results/${encodeURIComponent(fixture.resultId)}`
1847
+ const content = await request(harness, `${path}/content`)
1848
+ equal(content.status, 200, 'Result download status')
1849
+ equal(content.headers.get('content-type')?.split(';')[0], 'application/json', 'Result download type')
1850
+ equal(content.headers.get('cache-control'), 'no-store', 'Result download cache policy')
1851
+ if (!content.headers.get('content-disposition')?.startsWith('attachment;')) fail('Result download must be an attachment.')
1852
+ const bytes = new Uint8Array(await content.arrayBuffer())
1853
+ equal(bytes.byteLength, full.result.bytes, 'Result download byte count')
1854
+ const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (byte) => byte.toString(16).padStart(2, '0')).join('')
1855
+ equal(digest, full.result.digest, 'Result download digest')
1856
+ equal(JSON.parse(new TextDecoder().decode(bytes)), fixture.value, 'Result download content')
1857
+ for (const query of ['pointer=invalid', 'offset=-1', 'limit=0', 'maxBytes=0', 'pointer=%2Frows&offset=999']) {
1858
+ await error(await request(harness, `${path}?${query}`), 400, 'run.invalid', 'Invalid result page')
1859
+ }
1860
+ for (const suffix of ['', '/content']) {
1861
+ await error(
1862
+ await request(harness, `/v1/runs/${encodeURIComponent(fixture.otherRunId)}/results/${encodeURIComponent(fixture.resultId)}${suffix}`),
1863
+ 404,
1864
+ 'flow.not-found',
1865
+ 'Result cannot cross Run boundary',
1866
+ )
1867
+ }
1868
+ },
1869
+ },
1870
+ ]
1871
+ }
1872
+
1873
+ export function pollControlApiConformanceCases(fixture: {
1874
+ readonly definition: TriggerKeySnapshot & { readonly type: 'poll' }
1875
+ readonly connectionId: string
1876
+ readonly config: Readonly<Record<string, JsonValue>>
1877
+ readonly field: string
1878
+ readonly options: readonly TriggerConfigOption[]
1879
+ readonly preview: PollTriggerTestResult
1880
+ }): readonly ControlApiConformanceCase[] {
1881
+ return [
1882
+ {
1883
+ name: 'reads Trigger options and previews a Poll without creating Runs or changing Live state',
1884
+ async verify(harness) {
1885
+ const api = client(harness)
1886
+ const flow = await api.createFlow('Poll controls', 'poll-controls')
1887
+ const changed = await json(
1888
+ await changeRequest(harness, flow.flowId, flow.draftRevisionId, [
1889
+ {
1890
+ kind: 'graph.node.create',
1891
+ nodeId: 'poll',
1892
+ node: {
1893
+ kind: 'poll',
1894
+ name: 'Poll',
1895
+ connectionId: fixture.connectionId,
1896
+ definition: fixture.definition,
1897
+ config: Object.fromEntries(Object.entries(fixture.config).map(([key, value]) => [key, { kind: 'value', value }])),
1898
+ pollTimes: [{ type: 'every', unit: 'minute', value: 5 }],
1899
+ },
1900
+ },
1901
+ ]),
1902
+ 200,
1903
+ 'Create Poll',
1904
+ )
1905
+ const draft = await api.getDraft(flow.flowId)
1906
+ equal(await api.listTriggerConfigOptions(flow.flowId, 'poll', fixture.field), fixture.options, 'Dynamic Trigger options')
1907
+ equal(await api.getDraft(flow.flowId), draft, 'Reading options preserves Draft')
1908
+ equal((await api.listFlowTriggerBindings(flow.flowId)).length, 0, 'Reading options does not create Live binding')
1909
+ await completePublish(
1910
+ harness,
1911
+ await publishRequest(harness, flow.flowId, changedRevisionId(changed, 'Poll Revision'), null, 'poll-publish'),
1912
+ 202,
1913
+ 'Publish Poll',
1914
+ )
1915
+ const before = await api.getFlowTriggerBinding(flow.flowId, 'poll')
1916
+ equal(await api.testFlowPollTrigger(flow.flowId, 'poll'), fixture.preview, 'Poll preview response')
1917
+ equal(await api.testFlowPollTrigger(flow.flowId, 'poll'), fixture.preview, 'Preview does not consume events')
1918
+ equal(await api.getFlowTriggerBinding(flow.flowId, 'poll'), before, 'Preview preserves Live binding')
1919
+ equal((await api.listRuns(flow.flowId)).runs, [], 'Options and preview do not admit Runs')
1920
+ const base = `/v1/flows/${encodeURIComponent(flow.flowId)}/triggers`
1921
+ await error(await request(harness, `${base}/poll/options/conformance.missing`), 409, 'trigger-key.invalid', 'Unknown option field')
1922
+ await error(
1923
+ await request(harness, `${base}/missing/test`, { method: 'POST', body: JSON.stringify({ version: 1 }) }),
1924
+ 404,
1925
+ 'trigger.not-found',
1926
+ 'Missing Poll binding',
1927
+ )
1928
+ },
1929
+ },
1930
+ ]
1931
+ }
1932
+
1933
+ export function connectorScopeControlApiConformanceCases(fixture: {
1934
+ readonly scopes: readonly [
1935
+ { readonly flowId: string; readonly connections: readonly ConnectorConnection[] },
1936
+ { readonly flowId: string; readonly connections: readonly ConnectorConnection[] },
1937
+ ]
1938
+ }): readonly ControlApiConformanceCase[] {
1939
+ return [
1940
+ {
1941
+ name: 'isolates Connector Connections and conditional responses between Flows',
1942
+ async verify(harness) {
1943
+ const [first, second] = fixture.scopes
1944
+ if (dequal(first.connections, second.connections)) fail('Scope fixture must expose distinct Connection lists.')
1945
+ const api = client(harness)
1946
+ const services = new Set(fixture.scopes.flatMap((scope) => scope.connections.map((connection) => connection.serviceId)))
1947
+ let previousETag: string | undefined
1948
+ for (const scope of fixture.scopes) {
1949
+ equal(await api.listAllConnectorConnections(undefined, scope.flowId), scope.connections, 'Flow Connection authority')
1950
+ for (const service of services) {
1951
+ equal(
1952
+ await api.listConnectorConnections(service, undefined, scope.flowId),
1953
+ scope.connections.filter((connection) => connection.serviceId == service),
1954
+ 'Service Connection authority',
1955
+ )
1956
+ }
1957
+ const path = `/v1/connector/connections?flowId=${encodeURIComponent(scope.flowId)}`
1958
+ const response = await request(harness, path, { headers: previousETag == null ? {} : { 'if-none-match': previousETag } })
1959
+ equal(response.status, 200, 'Another Flow cannot reuse a different Connection representation')
1960
+ previousETag = requiredString(response.headers.get('etag'), 'Scoped Connection ETag')
1961
+ await revalidate(harness, path)
1962
+ }
1963
+ },
1964
+ },
1965
+ ]
1966
+ }
1967
+
1968
+ export function draftRepairControlApiConformanceCases(fixture: {
1969
+ readonly flowId: string
1970
+ readonly revisionId: string
1971
+ readonly content: RevisionContent
1972
+ }): readonly ControlApiConformanceCase[] {
1973
+ return [
1974
+ {
1975
+ name: 'repairs an unreadable Draft into the expected readable child Revision',
1976
+ async verify(harness) {
1977
+ const api = client(harness)
1978
+ const presentation = await api.getPresentation(fixture.flowId)
1979
+ const changed = await api.repairDraft(fixture.flowId, fixture.revisionId, 'repair-unreadable')
1980
+ equal(changed.revision.parentRevisionId, fixture.revisionId, 'Unreadable repair parent')
1981
+ const editor = await api.getEditor(fixture.flowId)
1982
+ equal(editor.draft.revisionId, changed.revision.revisionId, 'Repaired editor Revision')
1983
+ equal(editor.draft.content, fixture.content, 'Repaired content retains readable entries')
1984
+ equal(editor.presentation, presentation, 'Repair preserves layout')
1985
+ equal(await api.repairDraft(fixture.flowId, fixture.revisionId, 'repair-unreadable'), changed, 'Unreadable repair replay')
1986
+ },
1987
+ },
1988
+ ]
1989
+ }