@anchrd/intel 0.59.0

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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,2216 @@
1
+ import { flowNodeLayer } from "../contract/flow.js";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
3
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
4
+ import { IntelError } from "../shared/intel-error/intel-error.js";
5
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
6
+ function invalid(detail) {
7
+ throw new IntelError(400, "flow_graph_invalid", detail);
8
+ }
9
+ // A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
10
+ // walk and the run trail, both of which follow data that other writers can change.
11
+ const MaxCallDepth = 20;
12
+ // All four, and unlike a node this needs no lookup: every verb means something on a flow. `read`
13
+ // opens it, `write` edits it, `execute` runs it, `share` passes access on (#530).
14
+ const flowVerbs = ["read", "write", "execute", "share"];
15
+ /**
16
+ * The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own.
17
+ *
18
+ * ⚠️ Never `isAdmin` and never `canRun`. Whether Gate hands this person `intel/admin` or `flows/run`
19
+ * is Gate's to know, so the warning built on this can be pessimistic and never permissive — it may
20
+ * say "they will not be able to reach this" about somebody who turns out to be able to. The other
21
+ * direction would be a warning that stays silent about a real gap.
22
+ *
23
+ * A grant to an email address is judged as that address, which is what the grant will be attached
24
+ * to; a second grant the same person holds under their user id is not folded in.
25
+ */
26
+ function asPrincipalActor(principal) {
27
+ if (principal.type === "user")
28
+ return { id: principal.id, email: "", canRun: false };
29
+ if (principal.type === "email")
30
+ return { id: "", email: principal.email, canRun: false };
31
+ return { id: "", email: "", canRun: false };
32
+ }
33
+ // The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
34
+ // names a portal tool rather than a resource, and every rule about reachability applies to these
35
+ // four and not to it.
36
+ export const treeLinkKinds = ["folder", "document", "upload", "table"];
37
+ /**
38
+ * One place reads a graph for each kind of step it contains, and everything else is derived from
39
+ * these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
40
+ * list and the repository's cycle walk all ask about the same nodes; a second `kind === "…"` walk
41
+ * beside them would drift quietly, because both would keep returning something plausible.
42
+ */
43
+ export function subflowNodes(graph) {
44
+ return graph.nodes.filter((node) => node.kind === "subflow");
45
+ }
46
+ // ⚠️ Kept as one list although it is now four kinds: every caller asks "what does this graph read
47
+ // from the tree", never "which of the four". Splitting the callers to match the split in the schema
48
+ // would multiply four ways at each of the six call sites for no answer anyone wants.
49
+ export function treeLinkNodes(graph) {
50
+ return graph.nodes.filter((node) => treeLinkKinds.includes(node.kind));
51
+ }
52
+ export function toolNodes(graph) {
53
+ return graph.nodes.filter((node) => node.kind === "tool");
54
+ }
55
+ // The one resource a link names. Its own function because four kinds answer it the same way, and a
56
+ // caller that switched on the kind to read the same field would invite a fifth kind to be forgotten.
57
+ export function resourceIdOf(node) {
58
+ return node.configuration.resourceId;
59
+ }
60
+ // The flows a graph calls, in the order the nodes name them and without repetition. `subflowNodes`
61
+ // answers "which calls", this answers "which flows" — a flow called twice with two different version
62
+ // choices is two calls and one callee, and the freeze has to see both.
63
+ export function calleeIds(graph) {
64
+ const ids = [];
65
+ for (const node of subflowNodes(graph)) {
66
+ if (!ids.includes(node.configuration.flowId))
67
+ ids.push(node.configuration.flowId);
68
+ }
69
+ return ids;
70
+ }
71
+ /**
72
+ * The documents a graph names and the tool SERVERS it reaches for, flattened and without repetition.
73
+ * The requirements list, the publish-time check and the run's first tool check read this one answer,
74
+ * so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
75
+ * a document reads the node lists above instead — the relation graph draws exactly that edge.
76
+ *
77
+ * ⚠️ Servers, not functions, since #489. A step no longer names one function, so "what does this
78
+ * flow need" cannot be answered with a function list any more — and answering it with the functions
79
+ * a step MIGHT call would be a guess about a choice that is made while the flow runs.
80
+ */
81
+ export function graphReferences(graph) {
82
+ const nodes = [];
83
+ const servers = [];
84
+ for (const node of treeLinkNodes(graph)) {
85
+ if (!nodes.includes(node.configuration.resourceId)) {
86
+ nodes.push(node.configuration.resourceId);
87
+ }
88
+ }
89
+ for (const node of toolNodes(graph)) {
90
+ if (!servers.includes(node.configuration.server))
91
+ servers.push(node.configuration.server);
92
+ }
93
+ return { nodes, servers };
94
+ }
95
+ // ⚠️ The reason, in the words the person can act on, and not one word more. How many documents a
96
+ // step cannot reach is something they may know; which ones they are is the very thing the ACL is
97
+ // keeping from them, so the step is named and the documents are only counted (#17, #19).
98
+ function treeLinkStepDetail(label, missing) {
99
+ return missing === 1
100
+ ? `One document this step needs is not readable for you: ${label}`
101
+ : `${missing} documents this step needs are not readable for you: ${label}`;
102
+ }
103
+ // ⚠️ For tools this is the only honest moment there is. The catalog is a live tools/list with the
104
+ // requesting user's own token (ADR-0003), so nobody can be told in advance what someone else would
105
+ // see — but the person in front of the failure can be told exactly where to go.
106
+ // ⚠️ Servers, not tools, since #489 — and the sentence says so. A step names a server, so a person
107
+ // told "you do not have access to this tool: notion" would go looking for a function by that name
108
+ // and find none. What they can act on is the server: it is what the portal grants and what they can
109
+ // ask to be granted.
110
+ function toolStepDetail(missing) {
111
+ return missing.length === 1
112
+ ? `You do not reach this tool server in the portal: ${missing.join(", ")}`
113
+ : `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
114
+ }
115
+ /**
116
+ * ⚠️ How every refusal on this file names a node: by the label the canvas draws, because the id
117
+ * appears nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author
118
+ * looking for a string the editor never draws, and the way to the cause led through the contract
119
+ * schema instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP
120
+ * as readily as from the canvas, and there the id is what the caller holds.
121
+ *
122
+ * ⚠️ One function rather than one spelling per call site (#551): the publish-time refusals name a
123
+ * node too, and two places building the same string by hand is how the two halves of one sentence
124
+ * drift while both keep looking right.
125
+ */
126
+ function nodeName(node) {
127
+ return `"${node.label}" (${node.id})`;
128
+ }
129
+ export function compileFlow(graph) {
130
+ const nodes = new Map();
131
+ // ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
132
+ // it too — that one names the node the map already holds, which is the FIRST of the two. An id
133
+ // nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
134
+ const named = (id) => {
135
+ const node = nodes.get(id);
136
+ return node ? nodeName(node) : id;
137
+ };
138
+ for (const node of graph.nodes) {
139
+ if (nodes.has(node.id))
140
+ invalid(`Duplicate node ID: ${named(node.id)}`);
141
+ nodes.set(node.id, node);
142
+ }
143
+ // ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
144
+ // step", not "this runs after it" — counted in, it would give its target a second incoming edge
145
+ // and its source a second branch, and every graph carrying context would be rejected (#37).
146
+ const edgeIds = new Set();
147
+ const incoming = new Map();
148
+ const outgoing = new Map();
149
+ const attachments = new Map();
150
+ const attachedTo = new Map();
151
+ for (const edge of graph.edges) {
152
+ if (edgeIds.has(edge.id))
153
+ invalid(`Duplicate edge ID: ${edge.id}`);
154
+ edgeIds.add(edge.id);
155
+ if (!nodes.has(edge.source) || !nodes.has(edge.target))
156
+ invalid(`Dangling edge: ${edge.id}`);
157
+ if (edge.kind === "context") {
158
+ if (edge.source === edge.target)
159
+ invalid(`Context edge ${edge.id} cannot attach a node to itself`);
160
+ // One holder per attached node: two steps claiming the same material would make "what does
161
+ // this step work with" answerable two ways, and the second answer would never be shown.
162
+ if (attachedTo.has(edge.target))
163
+ invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
164
+ attachedTo.set(edge.target, edge.source);
165
+ attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
166
+ continue;
167
+ }
168
+ incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1);
169
+ outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
170
+ }
171
+ const triggers = graph.nodes.filter((node) => node.kind === "trigger");
172
+ if (triggers.length !== 1)
173
+ invalid("A flow requires exactly one trigger");
174
+ if (!graph.nodes.some((node) => node.kind === "output"))
175
+ invalid("A flow requires an output");
176
+ const trigger = triggers[0];
177
+ if (!trigger)
178
+ invalid("A flow requires exactly one trigger");
179
+ // The start is where the flow begins, not material a step works with. Attaching it would take it
180
+ // out of the run of steps below and leave the graph without a beginning.
181
+ if (attachedTo.has(trigger.id))
182
+ invalid("The trigger cannot be attached as context");
183
+ for (const node of graph.nodes) {
184
+ const parents = incoming.get(node.id) ?? 0;
185
+ const children = outgoing.get(node.id) ?? [];
186
+ const layer = flowNodeLayer[node.kind];
187
+ const holderId = attachedTo.get(node.id);
188
+ // ⚠️ The layer rule of D25, and the reason it lives here rather than in the editor: a graph
189
+ // arrives over MCP as readily as from the canvas. A link is what a step works with and is never
190
+ // in the order of work; a marker and a step are the order of work and are never material.
191
+ // Before this, a link could stand in the chain — which is how a start with an attachment once
192
+ // began its run at the attachment (#37).
193
+ if (layer === "link" && holderId === undefined) {
194
+ invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
195
+ }
196
+ if (layer !== "link" && holderId !== undefined) {
197
+ invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
198
+ }
199
+ if (holderId !== undefined) {
200
+ if (parents !== 0 || children.length !== 0) {
201
+ invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
202
+ }
203
+ const holder = nodes.get(holderId);
204
+ // Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
205
+ // flow it names brings its own links, and lending it one from here would be steering another
206
+ // flow from outside.
207
+ if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
208
+ invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
209
+ }
210
+ continue;
211
+ }
212
+ if (node.kind === "trigger" && parents !== 0)
213
+ invalid("The trigger cannot have an incoming edge");
214
+ if (node.kind !== "trigger" && parents !== 1) {
215
+ invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
216
+ }
217
+ if (node.kind === "output" && children.length !== 0)
218
+ invalid(`Output ${named(node.id)} must be terminal`);
219
+ if (node.kind === "condition") {
220
+ if (children.length < 2)
221
+ invalid(`Node ${named(node.id)} requires at least two branches`);
222
+ const handles = new Set(children.map((edge) => edge.sourceHandle));
223
+ if (handles.has(null) || handles.size !== children.length) {
224
+ invalid(`Node ${named(node.id)} requires unique branch handles`);
225
+ }
226
+ }
227
+ else if (node.kind !== "output") {
228
+ if (children.length !== 1)
229
+ invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
230
+ if (children[0]?.sourceHandle !== null)
231
+ invalid(`Node ${named(node.id)} cannot define a branch handle`);
232
+ }
233
+ }
234
+ const visited = new Set();
235
+ const active = new Set();
236
+ function visit(nodeId) {
237
+ if (active.has(nodeId))
238
+ invalid("Flow graphs cannot contain cycles");
239
+ if (visited.has(nodeId))
240
+ return;
241
+ active.add(nodeId);
242
+ // Only flow edges are walked: the cycle rule is about the order of work, and context is not in
243
+ // it. Attachments are marked visited so reachability still accounts for them, but stepping
244
+ // through one could never come back around — an attached node carries no flow edges at all.
245
+ //
246
+ // ⚠️ Context is one level deep, on purpose. Marking an attachment visited without recursing
247
+ // means material hung off material is unreachable and the graph is refused. A chain of "what
248
+ // this works with, works with…" is a structure nobody asked for, and allowing it would make
249
+ // "what does this step work with" a traversal instead of a lookup.
250
+ for (const edge of outgoing.get(nodeId) ?? [])
251
+ visit(edge.target);
252
+ for (const edge of attachments.get(nodeId) ?? [])
253
+ visited.add(edge.target);
254
+ active.delete(nodeId);
255
+ visited.add(nodeId);
256
+ }
257
+ visit(trigger.id);
258
+ if (visited.size !== graph.nodes.length)
259
+ invalid("Every node must be reachable from the trigger");
260
+ return { graph, triggerId: trigger.id };
261
+ }
262
+ // What a step says when it failed and left no reason behind, and what a call says when the reason
263
+ // belongs to a run the asker may not see. Both are written once and read from two places — the
264
+ // moment a run records, and the moment somebody reads it back — so a list cannot invent a third
265
+ // wording for the same event.
266
+ const StepFailedDetail = "Flow step failed";
267
+ function calledStepDetail(label) {
268
+ return `The called flow did not finish: ${label}`;
269
+ }
270
+ function nodeFor(version, nodeId) {
271
+ return nodeId ? (version.graph.nodes.find((node) => node.id === nodeId) ?? null) : null;
272
+ }
273
+ // Why a run started, read off the graph it ran rather than off a column. A called run says so
274
+ // through `parentRunId`; everything else was started by hand, because `manual` is the only mode a
275
+ // trigger node still has (#39).
276
+ //
277
+ // ⚠️ Still read from the graph rather than shortened to a constant. The graph is where the answer
278
+ // belongs, and if a second way of starting a flow is ever added it is added there — a hard-coded
279
+ // `manual` here would be a lie the day that happens, and nothing would point at this line.
280
+ function triggerOf(version, run) {
281
+ if (run.parentRunId)
282
+ return "subflow";
283
+ const trigger = version.graph.nodes.find((node) => node.kind === "trigger");
284
+ // `compileFlow` insists on exactly one trigger before a version can be saved, so the fallback is
285
+ // unreachable for anything that ever ran — it exists because the type cannot say that.
286
+ return trigger?.configuration.mode ?? "manual";
287
+ }
288
+ // Wall-clock duration of a finished run. A run still going has no answer yet, and inventing "so far"
289
+ // here would be a number that is stale the moment it is read.
290
+ function durationOf(run) {
291
+ if (!run.completedAt)
292
+ return null;
293
+ return Math.max(0, Date.parse(run.completedAt) - Date.parse(run.createdAt));
294
+ }
295
+ // A call site is two identifiers, and a Map wants one key. Written once so the lookup and the fill
296
+ // cannot disagree about how the pair is spelled; `\u0000` cannot occur in either half.
297
+ function callKey(runId, nodeId) {
298
+ return `${runId}\u0000${nodeId}`;
299
+ }
300
+ // What a call step may say to this asker. A called run they may see hands its own text over whole;
301
+ // otherwise the caller's step names itself and nothing else crosses the boundary. A step that
302
+ // recorded no reason keeps none — that is a completed call, not a silent failure.
303
+ function callDetail(calledRunId, label, stored) {
304
+ if (calledRunId)
305
+ return stored;
306
+ return stored === null ? null : calledStepDetail(label);
307
+ }
308
+ // The page marker of the run list. `created_at` alone is not unique — two runs of the same flow can
309
+ // share a millisecond — so the row's ID rides along and the keyset stays exact.
310
+ function encodeCursor(run) {
311
+ return `${run.createdAt}|${run.id}`;
312
+ }
313
+ function decodeCursor(cursor) {
314
+ if (!cursor)
315
+ return null;
316
+ const separator = cursor.indexOf("|");
317
+ if (separator <= 0)
318
+ throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
319
+ const createdAt = cursor.slice(0, separator);
320
+ const id = cursor.slice(separator + 1);
321
+ if (!id)
322
+ throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
323
+ return { createdAt, id };
324
+ }
325
+ // ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
326
+ // refusal follows, out of the same place (`callersPhrase`): a refusal must not become a way of
327
+ // learning that a flow one cannot see exists (ADR-0004 §3).
328
+ //
329
+ // ⚠️ The SENTENCE stays its own, and that is deliberate (#593): the folder's says a grant cannot be
330
+ // narrowed yet, this one says a flow cannot be deleted for good. Same facts, different consequence
331
+ // — a single parameterised sentence would save a string and cost each refusal its reason.
332
+ function callersDetail(callers) {
333
+ return `Published flows still call this one: ${callersPhrase(callers)}. Change or unpublish them first.`;
334
+ }
335
+ export function createFlows(deps) {
336
+ async function requireFlow(actor, flowId) {
337
+ const flow = await deps.repository.getVisible(actor, flowId);
338
+ if (!flow || flow.archivedAt)
339
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
340
+ return flow;
341
+ }
342
+ // ⚠️ On the execution path a flow is resolved by `read` or `execute`, never by `read` alone. A
343
+ // library folder carries `execute` for everyone and nothing else (ADR-0004 §2/§3), so insisting on
344
+ // `read` here would turn every library flow into a 404 for exactly the people it exists for — and
345
+ // the call rule in section 3 would have nothing left to permit.
346
+ /**
347
+ * Which of these flows call another one that THIS actor may also see (#59).
348
+ *
349
+ * ⚠️ Per reader, and that is the point: a flow whose only call is hidden from this person has
350
+ * nothing to unfold, so a chevron there would promise what opening cannot deliver. `listCalls`
351
+ * filters the same way — asking the same question here, rather than reading the graph and hoping,
352
+ * is the only way the two cannot drift apart.
353
+ *
354
+ * ⚠️ Two reads for the whole level, never one per flow (#30): the versions come back together and
355
+ * the callees go out together.
356
+ */
357
+ async function callableCallers(actor, flows) {
358
+ const versionOf = new Map();
359
+ for (const flow of flows) {
360
+ const versionId = flow.currentVersionId ?? flow.publishedVersionId;
361
+ if (versionId)
362
+ versionOf.set(flow.id, versionId);
363
+ }
364
+ if (versionOf.size === 0)
365
+ return [];
366
+ const versions = new Map((await deps.repository.getVersions([...new Set(versionOf.values())])).map((version) => [
367
+ version.id,
368
+ version,
369
+ ]));
370
+ const wanted = new Map();
371
+ for (const [flowId, versionId] of versionOf) {
372
+ const version = versions.get(versionId);
373
+ if (version)
374
+ wanted.set(flowId, calleeIds(version.graph));
375
+ }
376
+ const everyCallee = [...new Set([...wanted.values()].flat())];
377
+ if (everyCallee.length === 0)
378
+ return [];
379
+ const reachable = new Set((await deps.repository.listCallable(actor, everyCallee))
380
+ .filter((callee) => !callee.archivedAt)
381
+ .map((callee) => callee.id));
382
+ return [...wanted.entries()]
383
+ .filter(([, callees]) => callees.some((callee) => reachable.has(callee)))
384
+ .map(([flowId]) => flowId);
385
+ }
386
+ /**
387
+ * The flow a run already belongs to, as this actor may see it.
388
+ *
389
+ * ⚠️ The same ACL as `requireRunnableFlow` and deliberately without its archived refusal. This is
390
+ * the answer to "what should archiving do to a run in flight" (#112): archiving decides what may
391
+ * be STARTED, never what happens to what is already going. The other reading — refusing here —
392
+ * is what produced the state that ticket was written about: the step was recorded and the caller
393
+ * was told 404, with no way to read the run afterwards to find out otherwise.
394
+ */
395
+ async function requireStartedFlow(actor, flowId) {
396
+ const flow = await deps.repository.getCallable(actor, flowId);
397
+ if (!flow)
398
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
399
+ return flow;
400
+ }
401
+ // The same flow for something that is about to START — a run, a validation. An archived flow is
402
+ // absent here, which is the whole of what archiving does.
403
+ async function requireRunnableFlow(actor, flowId) {
404
+ const flow = await requireStartedFlow(actor, flowId);
405
+ if (flow.archivedAt)
406
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
407
+ return flow;
408
+ }
409
+ // Every surface maps the Gate capability into the actor, so the rule lives here once instead of
410
+ // being restated per surface. Checked before any storage access.
411
+ function requireRun(actor) {
412
+ if (!actor.canRun) {
413
+ throw new IntelError(403, "flow_run_forbidden", "Flow run permission is required");
414
+ }
415
+ }
416
+ // Managing a flow's sharing is `share` on the flow — held directly, passed down by a folder above
417
+ // it, or held by owning it. Asked before any grant row is read or written, so a refusal leaves no
418
+ // trace and tells the caller nothing about what grants exist.
419
+ async function requireShareable(actor, flowId) {
420
+ const flow = await requireFlow(actor, flowId);
421
+ if (!(await deps.repository.can(actor, flow.id, "share"))) {
422
+ throw new IntelError(403, "flow_forbidden", "Sharing of this flow cannot be managed");
423
+ }
424
+ return flow;
425
+ }
426
+ /**
427
+ * What this grant does NOT reach, described to whoever just made it (ADR-0004 §4).
428
+ *
429
+ * Two halves, and the second is the one a folder grant never needed. A grant on a folder covered
430
+ * every flow beneath it, so the sub-flows were covered with it; a grant on ONE flow stops at that
431
+ * flow, and the flows it calls are then a separate grant somebody has to make.
432
+ *
433
+ * ⚠️ It is a warning and not a refusal, for the reason the node side gives: blocking would force
434
+ * everyone whose flow reads a central policy document to duplicate it. What makes the warning
435
+ * enough — and what makes this whole feature possible at all — is that `validate` answers the same
436
+ * question later, for the person about to run, at the moment they run (#530).
437
+ *
438
+ * ⚠️ Titles only where the SHARER may see them. Whoever holds `share` on one flow must not learn
439
+ * the titles of documents or flows they cannot reach themselves, so the rest is a number. A
440
+ * warning must not become a way of reading the tree (ADR-0004 §3, #17's review).
441
+ */
442
+ async function withShareWarnings(actor, flow, grant, principal) {
443
+ const grantee = asPrincipalActor(principal);
444
+ // What the published version does is what a grantee will run. A flow with nothing published
445
+ // has nothing to warn about yet, and the draft is not what `execute` would reach.
446
+ const versionId = flow.publishedVersionId;
447
+ const version = versionId ? await deps.repository.getVersion(versionId) : null;
448
+ if (!version) {
449
+ return { grant, unreadable: empty(), unrunnable: empty() };
450
+ }
451
+ const unreadable = empty();
452
+ // Deduplicated: a document two steps both name is one thing the grantee cannot read, not two.
453
+ for (const nodeId of [...new Set(graphReferences(version.graph).nodes)]) {
454
+ if (await deps.visibleNodes(grantee, nodeId))
455
+ continue;
456
+ const node = await deps.visibleNodes(actor, nodeId);
457
+ if (node)
458
+ unreadable.titles.push(node.title);
459
+ else
460
+ unreadable.hidden += 1;
461
+ }
462
+ const unrunnable = empty();
463
+ for (const calleeId of calleeIds(version.graph)) {
464
+ if (await deps.repository.can(grantee, calleeId, "execute"))
465
+ continue;
466
+ const callee = await deps.repository.getVisible(actor, calleeId);
467
+ if (callee)
468
+ unrunnable.titles.push(callee.title);
469
+ else
470
+ unrunnable.hidden += 1;
471
+ }
472
+ return { grant, unreadable, unrunnable };
473
+ }
474
+ function empty() {
475
+ return { titles: [], hidden: 0 };
476
+ }
477
+ async function requireEdit(actor, flowId) {
478
+ const flow = await requireFlow(actor, flowId);
479
+ if (!(await deps.repository.can(actor, flowId, "write"))) {
480
+ throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
481
+ }
482
+ return flow;
483
+ }
484
+ // Seeing a flow and starting it are two grants, not one (ADR-0004 §2): /crm is readable for sales
485
+ // and executable only for billing. This is asked before anything a run would touch — no
486
+ // idempotency lookup, no portal call, no run row — so a refusal leaves no trace behind.
487
+ async function requireExecute(actor, flowId) {
488
+ if (!(await deps.repository.can(actor, flowId, "execute"))) {
489
+ throw new IntelError(403, "flow_execute_forbidden", "Flow execute permission is required");
490
+ }
491
+ }
492
+ /**
493
+ * Everything that stands between this actor and a run of this flow, gathered rather than thrown.
494
+ *
495
+ * ⚠️ THE reason this function exists rather than a second list beside `start`: two lists drift.
496
+ * They drift silently, and the direction they drift in is the worst one — `validate` keeps saying
497
+ * "it would run" while `start` refuses, which sends the reader looking anywhere but at the
498
+ * problem. `start` calls this and throws on the first entry; `validate` calls it and returns all
499
+ * of them. A check added here is therefore added to both, or to neither.
500
+ */
501
+ async function collectRunProblems(actor, flow, pinnedVersionId) {
502
+ const problems = [];
503
+ if (!(await deps.repository.can(actor, flow.id, "execute"))) {
504
+ problems.push({
505
+ status: 403,
506
+ code: "flow_execute_forbidden",
507
+ detail: "Flow execute permission is required",
508
+ });
509
+ }
510
+ // Asked even for a frozen call: a flow whose publication was withdrawn is not something a
511
+ // pinned version may quietly keep running.
512
+ if (!flow.publishedVersionId) {
513
+ problems.push({
514
+ status: 409,
515
+ code: "flow_not_published",
516
+ detail: "Flow has no published version",
517
+ });
518
+ return { problems, version: null, first: null };
519
+ }
520
+ // A frozen call runs its own version; everything else runs what is published now.
521
+ const version = await requireVersion(pinnedVersionId ?? flow.publishedVersionId, flow.id);
522
+ let first = null;
523
+ try {
524
+ const compiled = compileFlow(version.graph);
525
+ // ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
526
+ // node, because that edge can come first in the list (#37).
527
+ first =
528
+ version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target ?? null;
529
+ if (!first)
530
+ invalid("The trigger requires an outgoing edge");
531
+ }
532
+ catch (error) {
533
+ problems.push({
534
+ status: 400,
535
+ code: "flow_graph_invalid",
536
+ detail: error instanceof IntelError ? error.message : "The graph cannot be compiled",
537
+ });
538
+ return { problems, version, first: null };
539
+ }
540
+ // The flow may have been built by someone with wider portal access. Naming the missing tools
541
+ // before the first step beats failing halfway through with a portal error the user cannot
542
+ // place — and the portal is where they can do something about it.
543
+ //
544
+ // ⚠️ ONE entry per missing TOOL, and the unit is the decision (#435). A problem list is a
545
+ // count, so the number of entries has to be the number of things left to do — and what a
546
+ // person does about a missing tool is grant it once in the portal, whether one step reaches
547
+ // for it or six. `graphReferences` already folds the graph down to distinct tool names, which
548
+ // is why a tool hanging off several steps arrives here once. Deliberately NOT one entry per
549
+ // step: the same portal click would then be listed several times, and the reader would go
550
+ // looking for several causes.
551
+ //
552
+ // The one consequence to know about: `start` throws the FIRST entry, so it now names the first
553
+ // missing tool rather than all of them — the same as it has always done for several
554
+ // unpublished sub-flows below. `validate` is what lists every reason at once, and it does.
555
+ for (const server of await deps.unavailableServers(actor, graphReferences(version.graph).servers)) {
556
+ problems.push({
557
+ status: 403,
558
+ code: "flow_tools_unavailable",
559
+ detail: toolStepDetail([server]),
560
+ });
561
+ }
562
+ try {
563
+ // ⚠️ Tree links only. The loop above has already asked about every tool in the graph — the
564
+ // first step's included — so the tool half of `requireNodeAuthorized` would answer a second
565
+ // time about the same names, which is the duplicate #435 reported. Every place that actually
566
+ // hands a step over still calls the whole of `requireNodeAuthorized`.
567
+ await requireTreeLinksAuthorized(actor, nodeFor(version, first), version.graph);
568
+ }
569
+ catch (error) {
570
+ if (!(error instanceof IntelError))
571
+ throw error;
572
+ problems.push({ status: error.status, code: error.code, detail: error.message });
573
+ }
574
+ // Every flow this one calls has to be published, or the call fails halfway through a run rather
575
+ // than before it. `publish` refuses this at freeze time; a flow whose callee was un-published
576
+ // afterwards is the case only a check on demand can catch.
577
+ for (const node of subflowNodes(version.graph)) {
578
+ const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
579
+ if (!callee || callee.archivedAt) {
580
+ problems.push({
581
+ status: 409,
582
+ code: "flow_subflow_unavailable",
583
+ detail: `The called flow is not available: ${node.label}`,
584
+ });
585
+ }
586
+ else if (!callee.publishedVersionId) {
587
+ problems.push({
588
+ status: 409,
589
+ code: "flow_subflow_not_published",
590
+ detail: `The called flow has no published version: ${node.label}`,
591
+ });
592
+ }
593
+ }
594
+ return { problems, version, first };
595
+ }
596
+ // A flow's parent is a folder of the tree, so the answer comes from the node service rather than from a
597
+ // second permission model here. `null` is the root and needs no permission of its own — the same
598
+ // as creating a folder at the root does.
599
+ async function requireFolder(actor, parentId) {
600
+ if (parentId === null)
601
+ return;
602
+ const access = await deps.folderAccess(actor, parentId);
603
+ if (access === "missing") {
604
+ throw new IntelError(404, "flow_parent_not_found", "Parent folder was not found");
605
+ }
606
+ if (access === "not-a-folder") {
607
+ throw new IntelError(409, "parent_not_folder", "A flow's parent must be a folder");
608
+ }
609
+ if (access === "forbidden") {
610
+ throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
611
+ }
612
+ }
613
+ async function requireVersion(versionId, flowId) {
614
+ const version = await deps.repository.getVersion(versionId);
615
+ if (!version || (flowId && version.flowId !== flowId)) {
616
+ throw new IntelError(404, "flow_version_not_found", "Flow version was not found");
617
+ }
618
+ return version;
619
+ }
620
+ // Which step of which flow is running, outermost caller first. Built from the stored chain rather
621
+ // than from the run at hand, so a caller looking at the outer run sees the step inside the called
622
+ // flow, and a caller looking at the called run sees who called it.
623
+ async function trailFor(run, known) {
624
+ const chain = await deps.repository.runChain(run.id);
625
+ const byId = new Map(chain.map((entry) => [entry.runId, entry]));
626
+ const child = new Map();
627
+ for (const entry of chain) {
628
+ if (entry.parentRunId && byId.has(entry.parentRunId))
629
+ child.set(entry.parentRunId, entry.runId);
630
+ }
631
+ let head = chain.find((entry) => !entry.parentRunId || !byId.has(entry.parentRunId));
632
+ const ordered = [];
633
+ while (head && ordered.length < MaxCallDepth) {
634
+ ordered.push(head);
635
+ const next = child.get(head.runId);
636
+ head = next ? byId.get(next) : undefined;
637
+ }
638
+ // ⚠️ The versions in one read, after the walk rather than inside it (#30). This function sits on
639
+ // the path every step of every run takes, so a query per level was a cost paid per step and
640
+ // growing with the depth of the call chain — invisible at two levels, linear at twenty. The
641
+ // version already in hand is not asked for again, and a label whose version is missing stays
642
+ // `null` exactly as it did.
643
+ const versions = new Map([[known.id, known]]);
644
+ const missing = [...new Set(ordered.map((entry) => entry.versionId))].filter((versionId) => !versions.has(versionId));
645
+ for (const version of await deps.repository.getVersions(missing)) {
646
+ versions.set(version.id, version);
647
+ }
648
+ return ordered.map((entry) => {
649
+ const version = versions.get(entry.versionId);
650
+ return {
651
+ runId: entry.runId,
652
+ flowId: entry.flowId,
653
+ flowTitle: entry.flowTitle,
654
+ nodeId: entry.currentNodeId,
655
+ nodeLabel: version ? (nodeFor(version, entry.currentNodeId)?.label ?? null) : null,
656
+ };
657
+ });
658
+ }
659
+ // The documents of a list this actor may reach, named. Distinct IDs only, so a reference written
660
+ // twice is one document; an ID that is missing from the answer is one they cannot reach.
661
+ //
662
+ // ⚠️ It is `deps.visibleNodes` and nothing else, which is what keeps the authorization check,
663
+ // the requirements list and the relation graph on one rule. Only `id` and `title` travel onwards:
664
+ // the node also carries `parentId`, `ownerId` and its version, and where a document sits in the
665
+ // tree is not part of the question "what does this flow need" (#17, #19).
666
+ async function reachableNodes(actor, resourceIds) {
667
+ const reachable = [];
668
+ for (const resourceId of new Set(resourceIds)) {
669
+ const node = await deps.visibleNodes(actor, resourceId);
670
+ if (node)
671
+ reachable.push({ id: node.id, title: node.title });
672
+ }
673
+ return reachable;
674
+ }
675
+ // ⚠️ ADR-0004 §4, and the reason a subflow is not a way around anything. Before a node or
676
+ // Tool step is handed to whoever will carry it out — first run, retry and resume alike — the
677
+ // current Gate identity and the resource ACL are asked again. A called flow's steps come through
678
+ // this same function, so a document the user may not read stays unreadable however deep the call
679
+ // sits, and a flow grant keeps protecting the procedure rather than the data.
680
+ // ⚠️ Reads what hangs off the step, not the step itself (D25). A document or a tool is a link on a
681
+ // `context` edge now, and a run never stands on one — so asking "is this node a tree link"
682
+ // would ask about a node the run can no longer reach, and every check here would silently pass.
683
+ async function requireNodeAuthorized(actor, node, graph) {
684
+ await requireTreeLinksAuthorized(actor, node, graph);
685
+ await requireToolsAuthorized(actor, node, graph);
686
+ }
687
+ // The material of one step: what hangs off it on a `context` edge, as nodes.
688
+ function attachedNodes(node, graph) {
689
+ return graph.edges
690
+ .filter((edge) => edge.kind === "context" && edge.source === node.id)
691
+ .flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
692
+ }
693
+ // ⚠️ The two halves are separate functions only because `collectRunProblems` needs the first one
694
+ // without the second (#435): every tool of the whole graph is already asked about there, so
695
+ // asking again for the first step reported the same tool twice. `requireNodeAuthorized` above is
696
+ // what every execution boundary calls, and it is still both halves — splitting the CHECK would
697
+ // be the drift this file spends a page warning about; splitting the FUNCTION is not.
698
+ async function requireTreeLinksAuthorized(actor, node, graph) {
699
+ if (!node)
700
+ return;
701
+ const attached = attachedNodes(node, graph);
702
+ const wanted = [...new Set(treeLinkNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
703
+ if (!wanted.length)
704
+ return;
705
+ const reachable = await reachableNodes(actor, wanted);
706
+ if (reachable.length !== wanted.length) {
707
+ throw new IntelError(403, "flow_node_forbidden", treeLinkStepDetail(node.label, wanted.length - reachable.length));
708
+ }
709
+ }
710
+ async function requireToolsAuthorized(actor, node, graph) {
711
+ if (!node)
712
+ return;
713
+ const servers = attachedNodes(node, graph)
714
+ .filter((candidate) => candidate.kind === "tool")
715
+ .map((candidate) => candidate.configuration.server);
716
+ if (!servers.length)
717
+ return;
718
+ const missing = await deps.unavailableServers(actor, [...new Set(servers)]);
719
+ if (missing.length) {
720
+ throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
721
+ }
722
+ }
723
+ /**
724
+ * ⚠️ The boundary this whole slice turns on. A sub-flow step's reason for failing was written
725
+ * inside the *called* run, and that run is a run of its own with its own authorization — a library
726
+ * flow carries `execute` for everyone and `read` for nobody (ADR-0004 §3), so somebody who may
727
+ * read the calling flow can easily have no claim at all on what happened inside the called one.
728
+ *
729
+ * So the call sites are asked through the very predicate that opens a single run, and never
730
+ * through a second rule written for display. A site that comes back is one whose called run this
731
+ * actor may see: the stored text then travels and the called run is named so the reader can
732
+ * follow it. A site that stays out is reported by the *caller's* own label — the caller's to give
733
+ * — with a text that says no more than that the call did not finish. Named what they may see,
734
+ * counted the rest (#17, #19, #20).
735
+ *
736
+ * A whole page of sites in one read (#30): a list that asked per row would put two queries on
737
+ * every failed line, which is exactly the shape that ticket removed everywhere else.
738
+ */
739
+ async function visibleCalls(actor, sites) {
740
+ if (sites.length === 0)
741
+ return new Map();
742
+ const found = await deps.repository.visibleCallRuns(actor, sites);
743
+ return new Map(found.map((call) => [callKey(call.runId, call.nodeId), call.calledRunId]));
744
+ }
745
+ /**
746
+ * What a run looks like right now: the run as this actor may read it, the node it stands on, and
747
+ * the trail it belongs to. Data only — it asks nothing.
748
+ *
749
+ * ⚠️ Split out of `step` because of the order this used to be in (#112). `completeStep` writes
750
+ * the step and then shapes its answer, and shaping used to authorize a second time — so a check
751
+ * that failed AFTER the write told the caller "404" about work that had happened, and the retry
752
+ * replayed into the same 404 forever. Whoever just completed a step may be told what became of
753
+ * it. Whether they may run the NEXT one is asked when they run it, by the call that runs it.
754
+ *
755
+ * `narrowed` stays: it decides how much of a failure's text this reader may see, which is
756
+ * disclosure and not permission.
757
+ */
758
+ // ⚠️ The version is passed in rather than looked up. Both callers already hold it, and reading it
759
+ // again here cost one extra round trip per answer — which the query-count test caught (#30).
760
+ async function runState(actor, run, version) {
761
+ const node = nodeFor(version, run.currentNodeId);
762
+ return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
763
+ }
764
+ // The same picture for somebody ASKING for it rather than having just acted: the flow has to
765
+ // resolve and the node they would be handed has to be one they may execute.
766
+ async function step(actor, run) {
767
+ await requireStartedFlow(actor, run.flowId);
768
+ const version = await requireVersion(run.versionId, run.flowId);
769
+ await requireNodeAuthorized(actor, nodeFor(version, run.currentNodeId), version.graph);
770
+ return await runState(actor, run, version);
771
+ }
772
+ // A failed run carries the text of the step that failed, and for a call that text came out of
773
+ // another run. It is narrowed here rather than at the door it entered through: what storage keeps
774
+ // is what happened, and who may read it is a question about the person asking, not about the row.
775
+ //
776
+ // Only a failed run pays for this, and then two reads at most — a run that is still going leaves
777
+ // before the first one.
778
+ async function narrowed(actor, run, version) {
779
+ if (run.status !== "failed" || !run.error)
780
+ return run;
781
+ const failed = (await deps.repository.failedSteps([run.id]))[0];
782
+ const node = failed ? nodeFor(version, failed.nodeId) : null;
783
+ if (!failed || node?.kind !== "subflow")
784
+ return run;
785
+ const calls = await visibleCalls(actor, [{ runId: run.id, nodeId: failed.nodeId }]);
786
+ const detail = callDetail(calls.get(callKey(run.id, failed.nodeId)), node.label, run.error);
787
+ return detail === run.error ? run : { ...run, error: detail };
788
+ }
789
+ // ADR-0004 §3, checked where it can still be answered honestly: at publish time, naming the reason
790
+ // rather than the error. A call is allowed into the caller's own folder or below it, or into a
791
+ // folder whose `execute` reaches at least as far as the caller's own — in practice `execute` for
792
+ // everyone, the library.
793
+ async function requireCallRule(actor, flow, graph) {
794
+ for (const calleeId of calleeIds(graph)) {
795
+ const callee = await deps.repository.getCallable(actor, calleeId);
796
+ if (!callee || callee.archivedAt) {
797
+ throw new IntelError(409, "flow_subflow_unavailable", `Called flow is unavailable: ${calleeId}`);
798
+ }
799
+ if (callee.id === flow.id)
800
+ continue;
801
+ if ((await deps.repository.callReach(flow.parentId, callee.parentId)) === "out-of-reach") {
802
+ throw new IntelError(409, "flow_subflow_out_of_reach", `${flow.title} may not call ${callee.title}: it is neither in ${flow.title}'s folder nor below it, and its folder is not executable for everyone. Move it into the calling flow's subtree, or share its folder's execute with the organization.`);
803
+ }
804
+ }
805
+ }
806
+ // Calls point downwards, so a cycle cannot form inside one subtree. It can come back over the
807
+ // library edge, and that is what this walks — once, at publish time, naming the whole chain
808
+ // instead of the one edge that closed it.
809
+ async function requireNoCallCycle(flow, graph) {
810
+ const titles = new Map([[flow.id, flow.title]]);
811
+ const settled = new Set();
812
+ const named = (ids) => ids.map((id) => titles.get(id) ?? id).join(" → ");
813
+ async function walk(callees, path) {
814
+ for (const calleeId of callees) {
815
+ const closes = path.indexOf(calleeId);
816
+ if (closes !== -1) {
817
+ throw new IntelError(409, "flow_subflow_cycle", `Publishing would close a call cycle: ${named([...path.slice(closes), calleeId])}`);
818
+ }
819
+ if (settled.has(calleeId))
820
+ continue;
821
+ const next = await deps.repository.publishedCallees(calleeId);
822
+ settled.add(calleeId);
823
+ if (!next)
824
+ continue;
825
+ titles.set(calleeId, next.title);
826
+ if (path.length >= MaxCallDepth) {
827
+ throw new IntelError(409, "flow_subflow_too_deep", `Calls are nested more than ${MaxCallDepth} deep: ${named([...path, calleeId])}`);
828
+ }
829
+ await walk(next.calleeIds, [...path, calleeId]);
830
+ }
831
+ }
832
+ await walk(calleeIds(graph), [flow.id]);
833
+ }
834
+ // What each call of a graph will take, as the publishing author may see it (ADR-0004 §5). One
835
+ // reader for the preview and the freeze alike: if the screen computed this and publishing computed
836
+ // it again, the two would drift and the author would have agreed to something else than what
837
+ // happened.
838
+ //
839
+ // ⚠️ A callee this actor cannot reach is left out rather than described. The preview is a list of
840
+ // titles, and a title is exactly what an unreachable flow must not hand out — the same mistake the
841
+ // review of #17 found in an error message.
842
+ async function calls(actor, graph) {
843
+ const seen = new Map();
844
+ const items = [];
845
+ for (const node of subflowNodes(graph)) {
846
+ const calleeId = node.configuration.flowId;
847
+ if (!seen.has(calleeId)) {
848
+ const found = await deps.repository.getCallable(actor, calleeId);
849
+ seen.set(calleeId, found && !found.archivedAt ? found : null);
850
+ }
851
+ const callee = seen.get(calleeId) ?? null;
852
+ if (!callee)
853
+ continue;
854
+ const selection = node.configuration.version;
855
+ const pinned = selection.mode === "pinned" ? selection.versionId : (callee.publishedVersionId ?? null);
856
+ // ⚠️ Resolved *against the callee*, never by identifier alone. `getVersion` returns whatever
857
+ // version carries that ID, whichever flow it belongs to — so a pin naming a foreign version
858
+ // would have its sequence reported here, a fact about a flow the reader was never shown, and
859
+ // the very same pin would then be frozen into the published graph. This is the check the
860
+ // freeze makes, made in the same words, so the two cannot drift apart.
861
+ const version = pinned ? await requireVersion(pinned, calleeId) : null;
862
+ items.push({
863
+ nodeId: node.id,
864
+ nodeLabel: node.label,
865
+ calleeId,
866
+ calleeTitle: callee.title,
867
+ mode: selection.mode,
868
+ // A call that follows has no answer until it runs, and saying "version 4" here would be a
869
+ // promise the next publication of the callee breaks.
870
+ versionId: selection.mode === "follows" ? null : pinned,
871
+ versionSequence: selection.mode === "follows" ? null : (version?.sequence ?? null),
872
+ freezes: selection.mode === "latest",
873
+ available: Boolean(callee.publishedVersionId),
874
+ });
875
+ }
876
+ return items;
877
+ }
878
+ // ⚠️ ADR-0004 §5, and the reason a published flow keeps doing what it did. `latest` is replaced by
879
+ // the callee's published version here and nowhere else; without it, changing a building block
880
+ // would silently change every flow that uses it. `follows` is left exactly as it is — it was
881
+ // chosen on purpose, and overwriting it would take that choice away again.
882
+ //
883
+ // Versions are immutable, so this cannot rewrite the one being published: it returns the frozen
884
+ // graph and the caller appends it as a new version.
885
+ async function freeze(actor, graph) {
886
+ let changed = false;
887
+ const nodes = [];
888
+ for (const node of graph.nodes) {
889
+ // A tool step is frozen the same way a sub-flow call is: what it may reach is pinned at the
890
+ // moment of publishing (#489). The check above has already refused an unreachable server and
891
+ // a surface that moved since a previous publish, so this only writes down what it confirmed.
892
+ if (node.kind === "tool") {
893
+ const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
894
+ if (fingerprint !== null && fingerprint !== node.configuration.fingerprint) {
895
+ nodes.push({ ...node, configuration: { ...node.configuration, fingerprint } });
896
+ changed = true;
897
+ continue;
898
+ }
899
+ nodes.push(node);
900
+ continue;
901
+ }
902
+ if (node.kind !== "subflow") {
903
+ nodes.push(node);
904
+ continue;
905
+ }
906
+ const selection = node.configuration.version;
907
+ if (selection.mode === "pinned") {
908
+ // A pin is only worth anything while the version behind it is still there. Publishing a
909
+ // dangling pin would produce a flow that fails at its first call instead of at publish time.
910
+ await requireVersion(selection.versionId, node.configuration.flowId);
911
+ nodes.push(node);
912
+ continue;
913
+ }
914
+ // A call that rides along is left untouched, unpublished callee included: it resolves at run
915
+ // time by definition, and refusing here would forbid building a caller before its building
916
+ // block is finished — which is not what choosing "always latest" asked for.
917
+ if (selection.mode === "follows") {
918
+ nodes.push(node);
919
+ continue;
920
+ }
921
+ const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
922
+ if (!callee?.publishedVersionId) {
923
+ // There is nothing to freeze to. Refused here, where the reason is readable, rather than at
924
+ // the first run of the call, where it would arrive as someone else's flow failing.
925
+ throw new IntelError(409, "flow_subflow_not_published", `The called flow has nothing published to freeze to: ${node.label}`);
926
+ }
927
+ changed = true;
928
+ nodes.push({
929
+ ...node,
930
+ configuration: {
931
+ ...node.configuration,
932
+ version: { mode: "pinned", versionId: callee.publishedVersionId },
933
+ },
934
+ });
935
+ }
936
+ return changed ? { ...graph, nodes } : null;
937
+ }
938
+ // The place a call is made from. It names a step, never a permission: the callee's `execute` was
939
+ // already asked of the user, and the caller's is asked again here, so a grant revoked while the
940
+ // outer run waited cannot be walked around by starting the inner one.
941
+ async function requireCallSite(actor, parent, calleeId) {
942
+ const run = await deps.repository.getRunVisible(actor, parent.runId);
943
+ if (!run)
944
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
945
+ if (run.initiatedBy !== actor.id) {
946
+ throw new IntelError(403, "flow_call_site_forbidden", "The calling run belongs to someone else");
947
+ }
948
+ await requireExecute(actor, run.flowId);
949
+ if (run.status !== "running") {
950
+ throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
951
+ }
952
+ if (run.currentNodeId !== parent.nodeId) {
953
+ throw new IntelError(409, "flow_step_conflict", "A different step is currently active");
954
+ }
955
+ const version = await requireVersion(run.versionId, run.flowId);
956
+ const node = nodeFor(version, parent.nodeId);
957
+ if (node?.kind !== "subflow" || node.configuration.flowId !== calleeId) {
958
+ throw new IntelError(409, "flow_call_site_invalid", "That step does not call this flow");
959
+ }
960
+ // ⚠️ The version the call takes comes from the calling graph, which is immutable, and never from
961
+ // the request. A frozen call runs what it was frozen to however often the callee is published
962
+ // afterwards; a call that follows resolves to whatever is published at this moment, and the run
963
+ // row records which of the two it turned out to be (ADR-0004 §5).
964
+ const selection = node.configuration.version;
965
+ return { run, versionId: selection.mode === "pinned" ? selection.versionId : null };
966
+ }
967
+ return {
968
+ async list(actor, input = {}) {
969
+ const items = await deps.repository.listVisible(actor, input);
970
+ return { items, withCalls: await callableCallers(actor, items) };
971
+ },
972
+ async get(actor, flowId) {
973
+ const flow = await requireFlow(actor, flowId);
974
+ return {
975
+ flow,
976
+ version: flow.currentVersionId
977
+ ? await requireVersion(flow.currentVersionId, flow.id)
978
+ : null,
979
+ };
980
+ },
981
+ // The history of one flow, oldest first (#144). `requireFlow`, the same door as `get`: the
982
+ // history describes a flow this actor may already open, and it holds nothing a reader of the
983
+ // current graph could not learn — only when each edit happened and which one is live.
984
+ async listVersions(actor, flowId) {
985
+ const flow = await requireFlow(actor, flowId);
986
+ const items = await deps.repository.listVersions(flow.id);
987
+ return {
988
+ flowId: flow.id,
989
+ // Marked here, off the flow row already in hand: `published_version_id` is the flow's one
990
+ // answer, and asking storage to join it in would be a second place that could disagree.
991
+ items: items.map((version) => ({
992
+ ...version,
993
+ published: version.id === flow.publishedVersionId,
994
+ })),
995
+ };
996
+ },
997
+ // One version with its graph (#144). The flow resolves first, so a version ID belonging to a
998
+ // flow this actor may not see answers exactly like one that does not exist — `requireVersion`
999
+ // already refuses a version filed under another flow, in the same words.
1000
+ async getVersion(actor, input) {
1001
+ await requireFlow(actor, input.flowId);
1002
+ return await requireVersion(input.versionId, input.flowId);
1003
+ },
1004
+ // What accesses what, for the WHOLE subtree a folder holds (#825; one level until then, #19).
1005
+ // Flows answers it because the edges live in flow graphs; the node service answers which nodes
1006
+ // exist and who may see them, because the tree and its ACLs are the node service's (ADR-0004 §1).
1007
+ //
1008
+ // ⚠️ Every node passes an authorization before it is drawn, and a node that fails it is left out
1009
+ // entirely — no placeholder, no count, no edge. An edge to a grey box would already say that
1010
+ // something is there and that this flow touches it, which is the whole of what was meant to stay
1011
+ // hidden. `omitted` counts what the size limit cut, and nothing else.
1012
+ async relationGraph(actor, input) {
1013
+ const nodes = new Map();
1014
+ const edges = [];
1015
+ const dropped = new Set();
1016
+ const nodesSeen = new Map();
1017
+ const flowSeen = new Map();
1018
+ // Every folder actually walked, and every id its own bounded read actually returned. The two
1019
+ // together tell a later `place()` apart from a genuinely new omission: a node whose folder WAS
1020
+ // walked but who never came back within THAT folder's own bound is already inside `unread`,
1021
+ // and counting it again the moment a `reads`/`calls` edge reaches for it would make `omitted`
1022
+ // drift away from the number of nodes actually left out (#30). This generalises what used to
1023
+ // be a single `levelFolderId`/`levelIds` pair to the whole subtree the walk now covers (#825).
1024
+ const visitedFolderIds = new Set();
1025
+ const readIds = new Set();
1026
+ let unread = 0;
1027
+ // ⚠️ The radial tree's own structure (#825): a node reached again — first as a satellite of
1028
+ // some other flow's `reads` edge, later as a real member of the walked subtree — keeps the
1029
+ // TREE's placement, never the satellite's. `inScope` is what tells the two apart; depth and
1030
+ // parent travel with it. `realParentId` is bookkeeping only, never drawn: it is the node's
1031
+ // actual place in the tree, which for a satellite is not the same thing as the `null` parent
1032
+ // in its own `RelationNode` (see `seenNode`).
1033
+ function place(node, realParentId) {
1034
+ const existing = nodes.get(node.id);
1035
+ if (existing) {
1036
+ if (node.inScope && !existing.inScope)
1037
+ nodes.set(node.id, node);
1038
+ return true;
1039
+ }
1040
+ if (nodes.size >= input.limit) {
1041
+ if (!(visitedFolderIds.has(realParentId) && !readIds.has(node.id))) {
1042
+ dropped.add(node.id);
1043
+ }
1044
+ return false;
1045
+ }
1046
+ nodes.set(node.id, node);
1047
+ return true;
1048
+ }
1049
+ // ⚠️ Archived is decided here rather than behind the port, beside the identical rule
1050
+ // `calleeFlow` applies one function down. The port answers one question — may this actor see
1051
+ // it — because the run path asks it too, and a document being archived must not start
1052
+ // refusing steps under a message that names the wrong reason. What a drawing leaves out and
1053
+ // what a run refuses are two decisions; only the first belongs to #19.
1054
+ //
1055
+ // ⚠️ A node reached only this way is never part of the tree (#825): its own `RelationNode`
1056
+ // carries no parent and borrows the depth of whichever flow pointed at it, so the renderer
1057
+ // draws it as a satellite on a dashed `reads` edge rather than inventing a membership arc for
1058
+ // it. `parentId` in the RETURNED pair is the node's real parent all the same — `place` still
1059
+ // needs it to tell a genuinely new omission from one `unread` already counted.
1060
+ async function seenNode(nodeId, atDepth) {
1061
+ if (!nodesSeen.has(nodeId)) {
1062
+ const found = await deps.visibleNodes(actor, nodeId);
1063
+ nodesSeen.set(nodeId, found && !found.archivedAt
1064
+ ? {
1065
+ node: {
1066
+ id: found.id,
1067
+ kind: found.kind,
1068
+ title: found.title,
1069
+ inScope: false,
1070
+ parentId: null,
1071
+ depth: atDepth,
1072
+ },
1073
+ parentId: found.parentId,
1074
+ }
1075
+ : null);
1076
+ }
1077
+ return nodesSeen.get(nodeId) ?? null;
1078
+ }
1079
+ // The rows are already in hand — every callee of the subtree was read in one statement below —
1080
+ // so this is the archived rule and nothing else.
1081
+ function calleeFlow(flowId) {
1082
+ const found = flowSeen.get(flowId);
1083
+ return found && !found.archivedAt ? found : null;
1084
+ }
1085
+ // The subtree itself first, so a big folder spends its budget on its own contents rather than
1086
+ // on whatever the first flow in it happens to reach.
1087
+ //
1088
+ // ⚠️ `limit` bounds what is *read* as well as what is drawn (#30). Both reads stop at it, and
1089
+ // what they stopped short of comes back as a count instead of as rows. The order inside those
1090
+ // statements is the part that must not move: the visibility predicate first and the bound
1091
+ // after it, so the cut falls among the rows this actor may see — a bound applied first would
1092
+ // let a row they may never see take up a place in the picture, and the number below would then
1093
+ // be saying that it is there.
1094
+ const scopeFlows = [];
1095
+ if (input.scope.of === "flow") {
1096
+ const flow = await requireFlow(actor, input.scope.flowId);
1097
+ place({ id: flow.id, kind: "flow", title: flow.title, inScope: true, parentId: null, depth: 0 }, null);
1098
+ scopeFlows.push(flow);
1099
+ }
1100
+ else {
1101
+ const rootFolderId = input.scope.folderId;
1102
+ // The folder itself sits at the centre (depth 0). `null` names the whole tree, which has no
1103
+ // single folder to put there — its top-level items become the first ring instead, exactly as
1104
+ // a real root's children would (#825).
1105
+ if (rootFolderId !== null) {
1106
+ const root = await deps.visibleNodes(actor, rootFolderId);
1107
+ if (root && !root.archivedAt) {
1108
+ place({
1109
+ id: root.id,
1110
+ kind: root.kind,
1111
+ title: root.title,
1112
+ inScope: true,
1113
+ parentId: null,
1114
+ depth: 0,
1115
+ }, root.parentId);
1116
+ }
1117
+ }
1118
+ // Breadth-first over the whole subtree, shallowest folders first, so the budget above is
1119
+ // spent close to the root rather than on whichever branch happens to be walked first. A new
1120
+ // folder is queued only while there is still room for what it might hold; one already queued
1121
+ // is still visited in full, so what it could not fit is counted rather than silently dropped.
1122
+ const queue = [
1123
+ { folderId: rootFolderId, childDepth: 1 },
1124
+ ];
1125
+ while (queue.length > 0) {
1126
+ const next = queue.shift();
1127
+ if (!next)
1128
+ break;
1129
+ const { folderId, childDepth } = next;
1130
+ visitedFolderIds.add(folderId);
1131
+ const children = await deps.nodeChildren(actor, folderId, input.limit);
1132
+ unread += children.total - children.items.length;
1133
+ for (const item of children.items) {
1134
+ readIds.add(item.id);
1135
+ const kept = place({
1136
+ id: item.id,
1137
+ kind: item.kind,
1138
+ title: item.title,
1139
+ inScope: true,
1140
+ parentId: folderId,
1141
+ depth: childDepth,
1142
+ }, folderId);
1143
+ if (kept && item.kind === "folder" && nodes.size < input.limit) {
1144
+ queue.push({ folderId: item.id, childDepth: childDepth + 1 });
1145
+ }
1146
+ }
1147
+ const filed = await deps.repository.listVisibleBounded(actor, folderId, input.limit);
1148
+ unread += filed.total - filed.items.length;
1149
+ for (const flow of filed.items) {
1150
+ readIds.add(flow.id);
1151
+ if (place({
1152
+ id: flow.id,
1153
+ kind: "flow",
1154
+ title: flow.title,
1155
+ inScope: true,
1156
+ parentId: folderId,
1157
+ depth: childDepth,
1158
+ }, folderId)) {
1159
+ scopeFlows.push(flow);
1160
+ }
1161
+ }
1162
+ }
1163
+ }
1164
+ // The draft is what the author is looking at; the published graph is what a flow without one
1165
+ // still does. Reading neither would leave a flow in the picture with no edges at all.
1166
+ const versions = new Map((await deps.repository.getVersions([
1167
+ ...new Set(scopeFlows
1168
+ .map((flow) => flow.currentVersionId ?? flow.publishedVersionId)
1169
+ .filter((versionId) => versionId !== null)),
1170
+ ])).map((version) => [version.id, version]));
1171
+ const drawable = scopeFlows.flatMap((flow) => {
1172
+ const version = versions.get(flow.currentVersionId ?? flow.publishedVersionId ?? "");
1173
+ return version ? [{ flow, version }] : [];
1174
+ });
1175
+ // One read for the versions of the subtree and one for everything it calls, instead of a pair
1176
+ // per flow (#30). Both are bounded by the subtree, which is bounded by `limit`. Nothing below
1177
+ // changes because of it: the walk is the same walk in the same order, over rows already in
1178
+ // hand, so what is drawn and what is cut are what they were.
1179
+ for (const callee of await deps.repository.listCallable(actor, [
1180
+ ...new Set(drawable.flatMap(({ version }) => subflowNodes(version.graph).map((node) => node.configuration.flowId))),
1181
+ ])) {
1182
+ flowSeen.set(callee.id, callee);
1183
+ }
1184
+ for (const { flow, version } of drawable) {
1185
+ // Already placed above — this only borrows its depth for whatever it reaches or calls.
1186
+ const referrerDepth = nodes.get(flow.id)?.depth ?? 0;
1187
+ // The node lists rather than a walk of `graph.nodes` with two `kind` tests: an edge has to
1188
+ // name the step it comes from, so this is the one caller that needs the nodes themselves
1189
+ // and not the flattened `graphReferences`.
1190
+ for (const node of treeLinkNodes(version.graph)) {
1191
+ const resourceId = resourceIdOf(node);
1192
+ const target = await seenNode(resourceId, referrerDepth);
1193
+ if (!target || !place(target.node, target.parentId))
1194
+ continue;
1195
+ edges.push({
1196
+ id: `reads:${flow.id}:${node.id}:${resourceId}`,
1197
+ source: flow.id,
1198
+ target: resourceId,
1199
+ relation: "reads",
1200
+ });
1201
+ }
1202
+ for (const node of subflowNodes(version.graph)) {
1203
+ const callee = calleeFlow(node.configuration.flowId);
1204
+ if (!callee ||
1205
+ !place({
1206
+ id: callee.id,
1207
+ kind: "flow",
1208
+ title: callee.title,
1209
+ inScope: false,
1210
+ parentId: null,
1211
+ depth: referrerDepth,
1212
+ }, callee.parentId)) {
1213
+ continue;
1214
+ }
1215
+ edges.push({
1216
+ id: `calls:${flow.id}:${node.id}:${callee.id}`,
1217
+ source: flow.id,
1218
+ target: callee.id,
1219
+ relation: "calls",
1220
+ });
1221
+ }
1222
+ }
1223
+ // The outer ring (#825). Only a node the viewer holds `share` on gets an access edge at all —
1224
+ // `node_effective_access_list` refuses without it, and this graph must not hand out, node by
1225
+ // node, an answer that tool refuses right next to it. Bounded to the three rings the renderer
1226
+ // still draws as their own circles (depth 0 to 2, "drei Ringe offen"): the fourth ring on is
1227
+ // bundled into one labelled sector (#825), which has nowhere for a line to land. `flow` nodes
1228
+ // are skipped — sharing a flow and sharing a node are two different tables, and this reads only
1229
+ // the second.
1230
+ const users = new Map();
1231
+ const access = [];
1232
+ if (deps.nodeShareable && deps.nodeEffectiveAccess) {
1233
+ const nodeShareable = deps.nodeShareable;
1234
+ const nodeEffectiveAccess = deps.nodeEffectiveAccess;
1235
+ const candidates = [...nodes.values()].filter((node) => node.kind !== "flow" && node.depth <= 2);
1236
+ await Promise.all(candidates.map(async (node) => {
1237
+ if (!(await nodeShareable(actor, node.id)))
1238
+ return;
1239
+ const effective = await nodeEffectiveAccess(node.id);
1240
+ const userIds = new Set([
1241
+ ...effective.ownerIds,
1242
+ ...effective.items
1243
+ .map((grant) => grant.principal)
1244
+ .filter((principal) => principal.type === "user")
1245
+ .map((principal) => principal.id),
1246
+ ]);
1247
+ for (const userId of userIds) {
1248
+ users.set(userId, { id: userId });
1249
+ access.push({ userId, nodeId: node.id });
1250
+ }
1251
+ }));
1252
+ }
1253
+ return {
1254
+ scope: input.scope,
1255
+ nodes: [...nodes.values()],
1256
+ edges,
1257
+ users: [...users.values()],
1258
+ access,
1259
+ // What did not fit and what was never read for lack of room: two ways of being left out,
1260
+ // one number, and neither of them a permission (#19).
1261
+ omitted: dropped.size + unread,
1262
+ limit: input.limit,
1263
+ };
1264
+ },
1265
+ // Read out of the graph, never out of `parent_id`: what a flow calls is what it does, and where
1266
+ // it is filed is only where it is filed. A flow used by three callers is listed under all three.
1267
+ async listCalls(actor, flowId) {
1268
+ const flow = await requireFlow(actor, flowId);
1269
+ const versionId = flow.currentVersionId ?? flow.publishedVersionId;
1270
+ if (!versionId)
1271
+ return { items: [], withCalls: [] };
1272
+ const version = await requireVersion(versionId, flow.id);
1273
+ const wanted = calleeIds(version.graph);
1274
+ // One read for every callee rather than one per callee (#30). This is what the sidebar asks
1275
+ // when a flow is unfolded, so it runs while somebody is only looking around.
1276
+ //
1277
+ // ⚠️ The order stays the graph's, restored here from the storage layer's. A call list answers
1278
+ // "what do I run, and how", and that is the order the steps stand in; a callee this actor may
1279
+ // neither open nor run is absent from the answer, exactly as it was.
1280
+ const callable = new Map((await deps.repository.listCallable(actor, wanted)).map((callee) => [callee.id, callee]));
1281
+ const items = [];
1282
+ for (const calleeId of wanted) {
1283
+ const callee = callable.get(calleeId);
1284
+ if (callee && !callee.archivedAt)
1285
+ items.push(callee);
1286
+ }
1287
+ // A called flow calls flows of its own: the same question, one level deeper (#59).
1288
+ return { items, withCalls: await callableCallers(actor, items) };
1289
+ },
1290
+ // "What this flow needs", straight out of the graph: no arithmetic, nothing that can go stale,
1291
+ // and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
1292
+ // would be wrong for tools by construction — the catalog is a live query with the requesting
1293
+ // user's own token (ADR-0003) — and out of date for documents most of the time.
1294
+ /**
1295
+ * Would this flow start, for the person asking, right now? It answers and changes nothing: no
1296
+ * run row, no idempotency key, no audit event (#72).
1297
+ *
1298
+ * ⚠️ `read` is enough, deliberately. Somebody who may see a flow but not run it is exactly who
1299
+ * needs this — "you are missing execute" is the answer they came for, and refusing to answer
1300
+ * would leave them guessing at the one thing that is easy to say. Nothing is named here that a
1301
+ * reader could not already read out of the graph.
1302
+ *
1303
+ * ⚠️ Not cached and not stored anywhere. The tool catalog is a live query with the asking
1304
+ * user's own token (ADR-0003); a kept answer would be a claim about a moment that has passed.
1305
+ */
1306
+ async validate(actor, flowId) {
1307
+ const flow = await requireRunnableFlow(actor, flowId);
1308
+ const { problems, version } = await collectRunProblems(actor, flow, null);
1309
+ return {
1310
+ flowId: flow.id,
1311
+ versionId: version?.id ?? null,
1312
+ problems: problems.map(({ code, detail }) => ({ code, detail })),
1313
+ checkedAt: deps.now().toISOString(),
1314
+ };
1315
+ },
1316
+ async listRequirements(actor, flowId) {
1317
+ const flow = await requireFlow(actor, flowId);
1318
+ // The draft is what the editor is looking at; a flow with only a published version has
1319
+ // nothing else to describe.
1320
+ const versionId = flow.currentVersionId ?? flow.publishedVersionId;
1321
+ if (!versionId) {
1322
+ return {
1323
+ flowId: flow.id,
1324
+ versionId: null,
1325
+ nodes: [],
1326
+ invalidNodes: [],
1327
+ hiddenNodes: 0,
1328
+ servers: [],
1329
+ };
1330
+ }
1331
+ const version = await requireVersion(versionId, flow.id);
1332
+ const referenced = graphReferences(version.graph);
1333
+ // ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
1334
+ // What it hands back is named; a title is exactly what someone without access may not learn
1335
+ // (#17, #19). Tool names are not filtered: they come from a graph this actor may already
1336
+ // read, and whether the portal offers them is a question only their own token can answer.
1337
+ const reachable = await reachableNodes(actor, referenced.nodes);
1338
+ const named = new Set(reachable.map((reference) => reference.id));
1339
+ const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
1340
+ // ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
1341
+ // it" — a sentence about a permission, in front of a link that names nothing at all (#509).
1342
+ // The two suggest opposite actions and only one of them helps: with a permission one asks for
1343
+ // access, with a dead reference the step has to be replaced before the flow can run.
1344
+ //
1345
+ // ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
1346
+ // is asked without an actor because its answer does not depend on one. That is the narrowest
1347
+ // shape this distinction can have — the alternative, a lookup that is kinder than the door it
1348
+ // describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
1349
+ // deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
1350
+ // whether the tree still holds a row for it.
1351
+ const alive = new Set(await deps.repository.existingNodes(unreachable));
1352
+ const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
1353
+ return {
1354
+ flowId: flow.id,
1355
+ versionId: version.id,
1356
+ nodes: reachable,
1357
+ invalidNodes,
1358
+ hiddenNodes: unreachable.length - invalidNodes.length,
1359
+ servers: referenced.servers,
1360
+ };
1361
+ },
1362
+ async listGrants(actor, flowId) {
1363
+ const flow = await requireShareable(actor, flowId);
1364
+ return {
1365
+ resourceId: flow.id,
1366
+ applicableVerbs: flowVerbs,
1367
+ items: await deps.repository.listFlowGrants(flow.id),
1368
+ };
1369
+ },
1370
+ async listEffectiveAccess(actor, flowId) {
1371
+ const flow = await requireShareable(actor, flowId);
1372
+ const effective = await deps.repository.listEffectiveFlowAccess(flow.id);
1373
+ // ⚠️ **Empty here, and that is a gap rather than a decision** (anchrd/intel#807). A flow
1374
+ // inherits its grants from the FOLDERS above it, and naming one means reading a node title
1375
+ // through the node repository's visibility predicate — which this service deliberately does
1376
+ // not hold (`FlowRepository` knows flows). The screen falls back to saying the access is
1377
+ // inherited without saying from where, which is the same thing it says for a folder the
1378
+ // reader may not see, so nothing here reads as broken.
1379
+ return { resourceId: flow.id, ...effective, sources: [] };
1380
+ },
1381
+ async share(actor, input) {
1382
+ const flow = await requireShareable(actor, input.flowId);
1383
+ const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
1384
+ const principal = input.principal.type === "email"
1385
+ ? { type: "email", email: input.principal.email.toLowerCase() }
1386
+ : input.principal;
1387
+ if (replayedId) {
1388
+ const replayed = (await deps.repository.listFlowGrants(flow.id)).find((grant) => grant.id === replayedId);
1389
+ // A replay describes the same access the first attempt did, so the warning is asked again
1390
+ // rather than remembered: what the grantee can reach may have changed since.
1391
+ if (replayed)
1392
+ return await withShareWarnings(actor, flow, replayed, replayed.principal);
1393
+ }
1394
+ // ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
1395
+ // in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
1396
+ // a retry with the same key still answers with the grant its first attempt made. The long
1397
+ // form of the argument stands at the node grant in `nodes.ts`.
1398
+ requireFutureExpiry(input.expiresAt, deps.now());
1399
+ const timestamp = deps.now().toISOString();
1400
+ const grant = await deps.repository.setFlowGrant({
1401
+ grant: {
1402
+ id: deps.id(),
1403
+ resourceId: flow.id,
1404
+ principal,
1405
+ verb: input.verb,
1406
+ expiresAt: input.expiresAt,
1407
+ createdBy: actor.id,
1408
+ createdAt: timestamp,
1409
+ },
1410
+ flowId: flow.id,
1411
+ actorId: actor.id,
1412
+ idempotencyKey: input.idempotencyKey,
1413
+ auditId: deps.id(),
1414
+ });
1415
+ // ⚠️ After the grant is written, never before. The answer describes the access now in force,
1416
+ // and a warning computed a moment earlier would still list what this very grant just opened.
1417
+ return await withShareWarnings(actor, flow, grant, principal);
1418
+ },
1419
+ async revokeGrant(actor, input) {
1420
+ const flow = await requireShareable(actor, input.flowId);
1421
+ const replayed = await deps.repository.findIdempotentFlowRevocation(actor.id, input.idempotencyKey);
1422
+ if (replayed !== null)
1423
+ return { revoked: replayed };
1424
+ return {
1425
+ revoked: await deps.repository.revokeFlowGrant({
1426
+ flowId: flow.id,
1427
+ grantId: input.grantId,
1428
+ actorId: actor.id,
1429
+ idempotencyKey: input.idempotencyKey,
1430
+ auditId: deps.id(),
1431
+ occurredAt: deps.now().toISOString(),
1432
+ }),
1433
+ };
1434
+ },
1435
+ async create(actor, input) {
1436
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
1437
+ if (replayed)
1438
+ return await requireFlow(actor, replayed);
1439
+ await requireFolder(actor, input.parentId);
1440
+ const occurredAt = deps.now().toISOString();
1441
+ return await deps.repository.insertFlow({
1442
+ flow: {
1443
+ // A flow is offered at `flow_publish` and never at creation: publishing is the decision
1444
+ // that it is for other people, and an unpublished flow is in nobody's catalogue (#775).
1445
+ promptName: null,
1446
+ id: deps.id(),
1447
+ parentId: input.parentId,
1448
+ title: plainTitle(input.title),
1449
+ description: input.description,
1450
+ ownerId: actor.id,
1451
+ currentVersionId: null,
1452
+ publishedVersionId: null,
1453
+ createdAt: occurredAt,
1454
+ updatedAt: occurredAt,
1455
+ archivedAt: null,
1456
+ },
1457
+ actorId: actor.id,
1458
+ idempotencyKey: input.idempotencyKey,
1459
+ auditId: deps.id(),
1460
+ });
1461
+ },
1462
+ // Rename and move. It reads and writes the flow row alone: no version is appended, no published
1463
+ // version is touched and no run is signalled, so tidying up the tree cannot change what a flow
1464
+ // does (ADR-0004).
1465
+ async update(actor, input) {
1466
+ const current = await requireEdit(actor, input.flowId);
1467
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.update", input.idempotencyKey);
1468
+ if (replayed)
1469
+ return await requireFlow(actor, replayed);
1470
+ if (input.parentId !== undefined)
1471
+ await requireFolder(actor, input.parentId);
1472
+ const updated = await deps.repository.updateFlow({
1473
+ flow: {
1474
+ ...current,
1475
+ parentId: input.parentId === undefined ? current.parentId : input.parentId,
1476
+ title: input.title === undefined ? current.title : plainTitle(input.title),
1477
+ description: input.description === undefined ? current.description : input.description,
1478
+ updatedAt: deps.now().toISOString(),
1479
+ },
1480
+ baseUpdatedAt: input.baseUpdatedAt,
1481
+ actorId: actor.id,
1482
+ idempotencyKey: input.idempotencyKey,
1483
+ auditId: deps.id(),
1484
+ });
1485
+ if (updated === "conflict") {
1486
+ throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
1487
+ }
1488
+ return updated;
1489
+ },
1490
+ /**
1491
+ * ⚠️ The flow half of #457 — after this a flow is really gone, with its versions and the record
1492
+ * of every run it ever had. Read the decisions on that issue before touching it.
1493
+ */
1494
+ async purge(actor, input) {
1495
+ const current = await deps.repository.getVisible(actor, input.flowId);
1496
+ if (!current)
1497
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
1498
+ if (!(await deps.repository.can(actor, input.flowId, "write"))) {
1499
+ throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
1500
+ }
1501
+ // ⚠️ THE structural guard, same as the node's: a living flow has no path into nothing.
1502
+ if (!current.archivedAt) {
1503
+ throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
1504
+ }
1505
+ // A published caller would break at RUN TIME, in front of somebody who did not order this.
1506
+ // ⚠️ The callers travel as DATA beside the sentence (#593, the second half of #448): the
1507
+ // archive shows this refusal, and a German or Spanish screen could otherwise do nothing but
1508
+ // print the English one. The sentence stays for a model over MCP, which has nothing to
1509
+ // formulate with.
1510
+ const callers = await deps.repository.flowCallers(actor, current.id);
1511
+ if (callers.visible.length || callers.hidden) {
1512
+ throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers), namedOrCounted(callers));
1513
+ }
1514
+ // ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
1515
+ // belonging to other flows. Deleting them would take the record of who started those runs
1516
+ // with it — a history with a hole is not a history, so this refuses instead.
1517
+ if (await deps.repository.hasEntangledRuns(current.id)) {
1518
+ throw new IntelError(409, "flow_runs_entangled", "Runs of this flow are tied to runs of other flows. Deleting them would leave those without the record of what started them, or without the run they are waiting for.");
1519
+ }
1520
+ // ⚠️ Archiving deliberately leaves a run in flight alone, because restoring the flow makes it
1521
+ // readable again. A purge takes that way back with it — so an unfinished run refuses.
1522
+ if (await deps.repository.hasUnfinishedRuns(current.id)) {
1523
+ throw new IntelError(409, "flow_runs_unfinished", "This flow still has runs that have not finished. Wait for them or cancel them first.");
1524
+ }
1525
+ const purged = await deps.repository.purgeFlow({
1526
+ flowId: current.id,
1527
+ actorId: actor.id,
1528
+ auditId: deps.id(),
1529
+ occurredAt: deps.now().toISOString(),
1530
+ });
1531
+ if (purged === "missing") {
1532
+ throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
1533
+ }
1534
+ return { purged: true, title: current.title };
1535
+ },
1536
+ /**
1537
+ * Archive a flow, or take it back out again.
1538
+ *
1539
+ * ⚠️ The one place that resolves the flow through `getVisible` instead of `requireFlow`. Every
1540
+ * other entry point treats an archived flow as absent — that is the whole point of archiving —
1541
+ * but the call that restores one has to be able to find it, and `requireFlow` answers 404 for
1542
+ * exactly the rows this call exists for. The visibility predicate is the same one; only the
1543
+ * `archivedAt` refusal is left out.
1544
+ *
1545
+ * `write` and nothing more, asked before the row is touched: archiving is organization, the same
1546
+ * grant that renames and moves (ADR-0004). It appends no version and withdraws no publication.
1547
+ *
1548
+ * ⚠️ It does NOT leave runs in flight alone, however much the word "archive" suggests it would.
1549
+ * `completeStep` writes before it shapes its answer: the step is recorded, and `step()` then
1550
+ * resolves the flow and throws 404 for the archived one. The caller is told the step failed
1551
+ * while storage has it done, and a retry replays into the same 404. Restoring the flow is what
1552
+ * makes the run readable again. That belongs to the run machine and has its own ticket
1553
+ * (issue 112) — archiving is only the easiest way to walk into it.
1554
+ */
1555
+ async archive(actor, input) {
1556
+ const current = await deps.repository.getVisible(actor, input.flowId);
1557
+ if (!current)
1558
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
1559
+ if (!(await deps.repository.can(actor, input.flowId, "write"))) {
1560
+ throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
1561
+ }
1562
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.archive", input.idempotencyKey);
1563
+ // Read back the way this call reads anything, or replaying an archive would 404 on the row it
1564
+ // just archived.
1565
+ if (replayed) {
1566
+ const flow = await deps.repository.getVisible(actor, replayed);
1567
+ if (!flow)
1568
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
1569
+ return flow;
1570
+ }
1571
+ /**
1572
+ * ⚠️ **A flow cannot be restored on its own into an archived folder** (D71, #733, found in
1573
+ * review of #738). The same refusal `nodes.archive` carries, and for the same reason: a live
1574
+ * flow under an archived folder is reached by a purge of that folder, which checks
1575
+ * `archived_at` on its root alone and walks the whole subtree.
1576
+ */
1577
+ if (!input.archived && current.parentId !== null) {
1578
+ // ⚠️ Asked WITHOUT the visibility predicate, and that is the difference between a guard and
1579
+ // a decoration: a flow is reachable through `flow_grants` on its own, so its grantee may not
1580
+ // see the folder it is filed in. `folderAccess` answers `missing` for such a folder, the
1581
+ // guard would not fire, and a live flow would hang under an archived one — where the purge
1582
+ // of that folder takes it through `parent_id IN tree`.
1583
+ const folder = await deps.parentArchivedAt(current.parentId);
1584
+ if (folder?.archivedAt) {
1585
+ throw new IntelError(409, "parent_archived", "The folder this flow is filed in is archived, so it cannot be restored on its own. Restore the folder first, and this comes back with it.");
1586
+ }
1587
+ }
1588
+ const updatedAt = deps.now().toISOString();
1589
+ const updated = await deps.repository.archiveFlow({
1590
+ flowId: current.id,
1591
+ baseUpdatedAt: input.baseUpdatedAt,
1592
+ archivedAt: input.archived ? updatedAt : null,
1593
+ updatedAt,
1594
+ actorId: actor.id,
1595
+ idempotencyKey: input.idempotencyKey,
1596
+ auditId: deps.id(),
1597
+ });
1598
+ if (updated === "conflict") {
1599
+ throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
1600
+ }
1601
+ return updated;
1602
+ },
1603
+ async save(actor, input) {
1604
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
1605
+ if (replayed) {
1606
+ const version = await requireVersion(replayed, input.flowId);
1607
+ return { flow: await requireFlow(actor, input.flowId), version };
1608
+ }
1609
+ const flow = await requireEdit(actor, input.flowId);
1610
+ compileFlow(input.graph);
1611
+ if (flow.currentVersionId !== input.baseVersionId) {
1612
+ throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
1613
+ }
1614
+ const previous = flow.currentVersionId
1615
+ ? await requireVersion(flow.currentVersionId, flow.id)
1616
+ : null;
1617
+ const occurredAt = deps.now().toISOString();
1618
+ const version = {
1619
+ id: deps.id(),
1620
+ flowId: flow.id,
1621
+ sequence: (previous?.sequence ?? 0) + 1,
1622
+ graph: input.graph,
1623
+ createdBy: actor.id,
1624
+ createdAt: occurredAt,
1625
+ };
1626
+ const result = await deps.repository.insertVersion({
1627
+ version,
1628
+ baseVersionId: input.baseVersionId,
1629
+ actorId: actor.id,
1630
+ idempotencyKey: input.idempotencyKey,
1631
+ auditId: deps.id(),
1632
+ });
1633
+ if (result === "conflict") {
1634
+ throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
1635
+ }
1636
+ return { flow: await requireFlow(actor, flow.id), version };
1637
+ },
1638
+ // What publishing will do to the calls, before it does it (ADR-0004 §5). It reads and writes
1639
+ // nothing, and it asks for `write` rather than `read`: only someone who could publish has any
1640
+ // business reading which versions a publication would pin.
1641
+ async previewPublish(actor, input) {
1642
+ const flow = await requireEdit(actor, input.flowId);
1643
+ const version = await requireVersion(input.versionId, flow.id);
1644
+ // ⚠️ `available` is asked with the VERY call that publishing uses, not with a cheaper one that
1645
+ // answers a similar question. `unavailableServers` only says whether a server reaches
1646
+ // anything; publishing asks whether THIS step's surface is whole — and those differ exactly
1647
+ // where it hurts: a narrowed step whose allowed function disappeared keeps a reachable
1648
+ // server, so the cheap question says "fine" and the confirmation then answers 409. A preview
1649
+ // that promises a freeze the confirmation denies is worse than no preview.
1650
+ const tools = await Promise.all(toolNodes(version.graph).map(async (node) => ({
1651
+ nodeId: node.id,
1652
+ nodeLabel: node.label,
1653
+ server: node.configuration.server,
1654
+ allow: node.configuration.allow,
1655
+ available: (await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow)) !== null,
1656
+ })));
1657
+ return {
1658
+ flowId: flow.id,
1659
+ versionId: version.id,
1660
+ calls: await calls(actor, version.graph),
1661
+ tools,
1662
+ };
1663
+ },
1664
+ async publish(actor, input) {
1665
+ const flow = await requireEdit(actor, input.flowId);
1666
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.publish", input.idempotencyKey);
1667
+ if (replayed)
1668
+ return await requireFlow(actor, flow.id);
1669
+ const version = await requireVersion(input.versionId, flow.id);
1670
+ compileFlow(version.graph);
1671
+ const wanted = [...new Set(treeLinkNodes(version.graph).map(resourceIdOf))];
1672
+ const reachable = new Set((await reachableNodes(actor, wanted)).map((reference) => reference.id));
1673
+ // ⚠️ Asked over the deduplicated resource ids and REFUSED over the nodes (#551). The resource
1674
+ // id is what the ACL answers about, and it is also the one thing the author cannot look up:
1675
+ // it stands in no place the editor draws, so `Node reference is unavailable: n-01H…` named
1676
+ // the failure after the only participant nobody can find. The link node beside it carries the
1677
+ // label on the canvas, so the refusal names that instead.
1678
+ //
1679
+ // ⚠️ Naming the label reveals nothing the ACL is keeping: it is the author's own text in
1680
+ // their own graph, already on their screen — unlike the run-time refusal, which counts the
1681
+ // documents rather than naming them because there the READER may not see them (#17, #19).
1682
+ for (const node of treeLinkNodes(version.graph)) {
1683
+ if (!reachable.has(resourceIdOf(node))) {
1684
+ throw new IntelError(409, "flow_node_unavailable", `Node ${nodeName(node)} names a document you cannot reach`);
1685
+ }
1686
+ }
1687
+ for (const node of version.graph.nodes) {
1688
+ if (node.kind === "tool") {
1689
+ // ⚠️ The fingerprint covers the whole surface this step may use — the server and, when
1690
+ // the step narrows it, exactly those functions with their schemas (#489). Freezing one
1691
+ // function was enough while a step named one; a step that may use any function of a
1692
+ // server would otherwise silently inherit functions the provider added AFTER publishing.
1693
+ const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
1694
+ if (!fingerprint) {
1695
+ throw new IntelError(409, "flow_tool_unavailable", `Tool server is unavailable: ${node.label}`);
1696
+ }
1697
+ // ⚠️ A step that has never been published carries no fingerprint, and that is not a
1698
+ // mismatch — it is the state every draft starts in. Publishing is what FREEZES the
1699
+ // surface, exactly as it turns a `latest` sub-flow call into a pinned one, and `freeze`
1700
+ // below writes the value in.
1701
+ //
1702
+ // Demanding it up front would mean the editor had to produce it, and nothing hands it
1703
+ // one: the value is derived from the asking user's own live catalog. Requiring it would
1704
+ // make every tool step unpublishable rather than safe.
1705
+ if (node.configuration.fingerprint !== null &&
1706
+ node.configuration.fingerprint !== fingerprint) {
1707
+ throw new IntelError(409, "flow_tool_schema_changed", `Review the current schema before publishing: ${node.label}`);
1708
+ }
1709
+ }
1710
+ }
1711
+ // Both refusals happen before the flow is published, so a call that breaks the rule never
1712
+ // becomes something a run could follow.
1713
+ await requireCallRule(actor, flow, version.graph);
1714
+ await requireNoCallCycle(flow, version.graph);
1715
+ // ADR-0004 §5: `latest` becomes the concrete version here. A version is immutable, so the
1716
+ // freeze cannot rewrite the one being published — it appends the frozen graph as the next
1717
+ // version and publishes that. The author saw the same list through `previewPublish` first.
1718
+ const frozenGraph = await freeze(actor, version.graph);
1719
+ let publishable = version;
1720
+ if (frozenGraph) {
1721
+ // ⚠️ Keyed on the version being frozen, not on the request. Freezing version X is a
1722
+ // function of X, so every attempt at it has to land on the one version the first attempt
1723
+ // appended: a retry whose answer was lost, and a second confirmation of the same preview,
1724
+ // are re-entries rather than repetitions. With a per-request key the second attempt would
1725
+ // append a second version — and could then never publish, because the draft it was based on
1726
+ // is no longer the current one and the guard below would refuse it forever.
1727
+ const freezeKey = `freeze:${version.id}`;
1728
+ const already = await deps.repository.findIdempotent(actor.id, "flows.save", freezeKey);
1729
+ if (already) {
1730
+ publishable = await requireVersion(already, flow.id);
1731
+ }
1732
+ else {
1733
+ // Appending only ever continues the draft. Freezing an older version would fork the
1734
+ // chain and leave the editor holding a version that is no longer the flow's current one.
1735
+ if (flow.currentVersionId !== version.id) {
1736
+ throw new IntelError(409, "flow_publish_not_current", "Only the current version can be published while it still calls a latest sub-flow");
1737
+ }
1738
+ const frozen = {
1739
+ id: deps.id(),
1740
+ flowId: flow.id,
1741
+ sequence: version.sequence + 1,
1742
+ graph: frozenGraph,
1743
+ createdBy: actor.id,
1744
+ createdAt: deps.now().toISOString(),
1745
+ };
1746
+ const saved = await deps.repository.insertVersion({
1747
+ version: frozen,
1748
+ baseVersionId: version.id,
1749
+ actorId: actor.id,
1750
+ idempotencyKey: freezeKey,
1751
+ auditId: deps.id(),
1752
+ });
1753
+ if (saved === "conflict") {
1754
+ throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
1755
+ }
1756
+ publishable = frozen;
1757
+ }
1758
+ }
1759
+ const published = await deps.repository.publish({
1760
+ flowId: flow.id,
1761
+ versionId: publishable.id,
1762
+ actorId: actor.id,
1763
+ idempotencyKey: input.idempotencyKey,
1764
+ auditId: deps.id(),
1765
+ occurredAt: deps.now().toISOString(),
1766
+ // ⚠️ Spread rather than assigned, and `exactOptionalPropertyTypes` is only half the reason.
1767
+ // The repository distinguishes three states, and "the field is present carrying
1768
+ // `undefined`" is not one of them: it has to be ABSENT for "leave the name alone".
1769
+ ...(input.promptName === undefined ? {} : { promptName: input.promptName }),
1770
+ });
1771
+ if (published === "prompt_name_taken") {
1772
+ /**
1773
+ * ⚠️ **The holder is named only to somebody who may see it** — the same sentence
1774
+ * `node_update` gives, out of the same query (`db-prompts.ts`). The catalogue is one across
1775
+ * documents and flows, so the name a flow is refused for may well be held by a document,
1776
+ * and the refusal has to be able to say so.
1777
+ */
1778
+ const holder = await deps.repository.promptNameHolder(actor, input.promptName);
1779
+ const held = holder === null
1780
+ ? "It is already taken."
1781
+ : holder.title === null
1782
+ ? `A ${holder.kind} you cannot see already uses it.`
1783
+ : `The ${holder.kind} "${holder.title}" already uses it.`;
1784
+ throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
1785
+ }
1786
+ if (!published)
1787
+ throw new IntelError(409, "flow_publish_conflict", "Flow could not be published");
1788
+ return await requireFlow(actor, flow.id);
1789
+ },
1790
+ /**
1791
+ * Withdraw the publication (#146) — the operation the refusal in `knowledge.ts` has promised
1792
+ * all along ("Change or unpublish them before narrowing the folder"). `published_version_id`
1793
+ * goes back to null and nothing else moves: every version is kept, and republishing any of
1794
+ * them is one `publish` away.
1795
+ *
1796
+ * What it means downstream is already decided by the checks that exist: new starts answer
1797
+ * `flow_not_published`, a caller's `validate` reports `flow_subflow_not_published` for the
1798
+ * dead call, and runs in flight keep their immutable version — publication decides what may
1799
+ * START, never what happens to what is already going (#112). The narrowing guard reads only
1800
+ * published graphs, so an unpublished caller frees its library folder.
1801
+ */
1802
+ async unpublish(actor, input) {
1803
+ const flow = await requireEdit(actor, input.flowId);
1804
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.unpublish", input.idempotencyKey);
1805
+ if (replayed)
1806
+ return await requireFlow(actor, flow.id);
1807
+ if (!flow.publishedVersionId) {
1808
+ throw new IntelError(409, "flow_not_published", "Flow has no published version");
1809
+ }
1810
+ const withdrawn = await deps.repository.unpublish({
1811
+ flowId: flow.id,
1812
+ versionId: flow.publishedVersionId,
1813
+ actorId: actor.id,
1814
+ idempotencyKey: input.idempotencyKey,
1815
+ auditId: deps.id(),
1816
+ occurredAt: deps.now().toISOString(),
1817
+ });
1818
+ // The write matched nothing: another writer withdrew the publication first. The state is
1819
+ // what this call asked for, but saying "done" would hide that the race happened.
1820
+ if (!withdrawn) {
1821
+ throw new IntelError(409, "flow_not_published", "Flow has no published version");
1822
+ }
1823
+ return await requireFlow(actor, flow.id);
1824
+ },
1825
+ async start(actor, input) {
1826
+ requireRun(actor);
1827
+ // ⚠️ The whole guarantee of a subflow sits on this line staying where it is. A called run is
1828
+ // started through the same door as any other: the user's own `execute` on the called flow is
1829
+ // asked first, and the calling flow lends nothing. Without it a subflow node would be a way
1830
+ // around every folder grant in the tree (ADR-0004 §4).
1831
+ await requireExecute(actor, input.flowId);
1832
+ const call = input.parent ? await requireCallSite(actor, input.parent, input.flowId) : null;
1833
+ const parent = call?.run ?? null;
1834
+ // One call site starts one run. A repeated call hands back the run it already made instead of
1835
+ // a second one whose result nothing would ever read.
1836
+ if (parent && input.parent) {
1837
+ const existing = await deps.repository.findChildRun(parent.id, input.parent.nodeId);
1838
+ if (existing)
1839
+ return await step(actor, existing);
1840
+ }
1841
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.run", input.idempotencyKey);
1842
+ if (replayed) {
1843
+ const run = await deps.repository.getRunVisible(actor, replayed);
1844
+ if (run) {
1845
+ if (run.status === "running" || run.status === "queued")
1846
+ await deps.runtime.start(run.id);
1847
+ return await step(actor, run);
1848
+ }
1849
+ }
1850
+ const flow = await requireRunnableFlow(actor, input.flowId);
1851
+ // ⚠️ The same list `validate` reads, and the reason it is one list: a check that only one of
1852
+ // them knows is a check the other silently skips. Thrown here at the first entry, because a
1853
+ // start is an action and an action either happens or says why not.
1854
+ //
1855
+ // Nothing has been written at this point, so a refusal leaves nothing behind at all — not a
1856
+ // run, not an idempotency key, not an audit event.
1857
+ const check = await collectRunProblems(actor, flow, call?.versionId ?? null);
1858
+ const problem = check.problems[0];
1859
+ if (problem)
1860
+ throw new IntelError(problem.status, problem.code, problem.detail);
1861
+ const version = check.version;
1862
+ const first = check.first;
1863
+ if (!version || !first)
1864
+ invalid("The flow cannot be started");
1865
+ const occurredAt = deps.now().toISOString();
1866
+ // ⚠️ A flow whose start leads straight to its end has nothing to carry out, and the end is a
1867
+ // marker rather than a step (D25). It is the shape every flow has the moment it is created,
1868
+ // so it must run rather than park a run on a node nobody can complete. What it hands back is
1869
+ // its input: the only thing that passed through it.
1870
+ const empty = nodeFor(version, first)?.kind === "output";
1871
+ const run = {
1872
+ id: deps.id(),
1873
+ flowId: flow.id,
1874
+ versionId: version.id,
1875
+ status: empty ? "completed" : "running",
1876
+ currentNodeId: empty ? null : first,
1877
+ input: input.input,
1878
+ output: empty ? input.input : null,
1879
+ error: null,
1880
+ initiatedBy: actor.id,
1881
+ parentRunId: parent?.id ?? null,
1882
+ parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
1883
+ createdAt: occurredAt,
1884
+ updatedAt: occurredAt,
1885
+ completedAt: empty ? occurredAt : null,
1886
+ };
1887
+ const inserted = await deps.repository.insertRun({
1888
+ run,
1889
+ actorId: actor.id,
1890
+ idempotencyKey: input.idempotencyKey,
1891
+ auditId: deps.id(),
1892
+ });
1893
+ await deps.runtime.start(inserted.id);
1894
+ return await step(actor, inserted);
1895
+ },
1896
+ async getRun(actor, runId) {
1897
+ const run = await deps.repository.getRunVisible(actor, runId);
1898
+ if (!run)
1899
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
1900
+ return await step(actor, run);
1901
+ },
1902
+ /**
1903
+ * What this flow has done, newest first (#35). Until now a run could only be found by an ID
1904
+ * somebody still had, and closing the tab lost it — with nothing to look at afterwards, a run
1905
+ * that failed overnight was a run nobody heard about.
1906
+ *
1907
+ * ⚠️ Two rules meet here and they are deliberately different ones. Whether the *flow* exists for
1908
+ * this actor is `read` or `execute`, exactly as the run path asks it — insisting on `read` would
1909
+ * make a library flow's own runner unable to find their run back. Which *runs* then appear is
1910
+ * `getRunVisible`, per row, in the repository: their own, plus every run of a flow they may
1911
+ * read. So an execute-only runner sees their own and nobody else's, and the answer cannot drift
1912
+ * from what opening a single run would say.
1913
+ *
1914
+ * What comes back is what a run did, never what it produced: `FlowRunSummary` carries no input
1915
+ * and no output, because a run reaches nodes and tools with the rights of whoever started it.
1916
+ */
1917
+ async listRuns(actor, input) {
1918
+ // The history of a flow that has since been archived stays readable — the runs happened, and
1919
+ // archiving is about what may start next (#112).
1920
+ //
1921
+ // ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
1922
+ // answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
1923
+ // while which runs appear is decided per row in the repository — their own, plus every run of
1924
+ // a flow they may read. Asking it for a flow nobody named would mean inventing one.
1925
+ const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
1926
+ // One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
1927
+ // count over the whole table is never needed to draw a "next" affordance.
1928
+ const rows = await deps.repository.listRunsVisible(actor, {
1929
+ flowId,
1930
+ failedOnly: input.failedOnly,
1931
+ limit: input.limit + 1,
1932
+ cursor: decodeCursor(input.cursor),
1933
+ });
1934
+ const page = rows.slice(0, input.limit);
1935
+ const failed = new Map((await deps.repository.failedSteps(page.map((run) => run.id))).map((row) => [
1936
+ row.runId,
1937
+ row,
1938
+ ]));
1939
+ // ⚠️ Four reads for a page, whatever its length: the runs, their failed steps, the versions
1940
+ // they took, and the call sites among those failures. Runs of one flow mostly share a version
1941
+ // and most failures are not calls, so all three follow-ups are asked once for the whole page
1942
+ // rather than once per line (#30).
1943
+ const versions = new Map((await deps.repository.getVersions([...new Set(page.map((run) => run.versionId))])).map((version) => [version.id, version]));
1944
+ const sites = [];
1945
+ for (const run of page) {
1946
+ const record = failed.get(run.id);
1947
+ const version = record ? versions.get(run.versionId) : undefined;
1948
+ if (record && version && nodeFor(version, record.nodeId)?.kind === "subflow") {
1949
+ sites.push({ runId: run.id, nodeId: record.nodeId });
1950
+ }
1951
+ }
1952
+ const calls = await visibleCalls(actor, sites);
1953
+ const items = [];
1954
+ for (const run of page) {
1955
+ const version = versions.get(run.versionId) ?? null;
1956
+ const record = failed.get(run.id);
1957
+ let failure = null;
1958
+ if (record) {
1959
+ const node = version ? nodeFor(version, record.nodeId) : null;
1960
+ const label = node?.label ?? record.nodeId;
1961
+ const stored = record.error ?? StepFailedDetail;
1962
+ const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
1963
+ failure = {
1964
+ nodeId: record.nodeId,
1965
+ nodeLabel: label,
1966
+ detail: node?.kind === "subflow"
1967
+ ? (callDetail(calledRunId, label, stored) ?? stored)
1968
+ : stored,
1969
+ calledRunId: calledRunId ?? null,
1970
+ };
1971
+ }
1972
+ items.push({
1973
+ id: run.id,
1974
+ flowId: run.flowId,
1975
+ versionId: run.versionId,
1976
+ status: run.status,
1977
+ // A version that has gone missing cannot say how the run was triggered, and `manual` is
1978
+ // what every run that was not a call was started by (#39).
1979
+ trigger: version ? triggerOf(version, run) : run.parentRunId ? "subflow" : "manual",
1980
+ startedAt: run.createdAt,
1981
+ completedAt: run.completedAt,
1982
+ durationMs: durationOf(run),
1983
+ initiatedBy: run.initiatedBy,
1984
+ parentRunId: run.parentRunId,
1985
+ failure,
1986
+ });
1987
+ }
1988
+ const last = page.at(-1);
1989
+ return {
1990
+ items,
1991
+ nextCursor: rows.length > page.length && last ? encodeCursor(last) : null,
1992
+ };
1993
+ },
1994
+ /**
1995
+ * One run step by step, which is the answer to "what went wrong" when the failure is three steps
1996
+ * in. The failed node is reported rather than drawn onto the canvas: a run took an immutable
1997
+ * version, the editor shows the current one, and marking a node in the wrong graph would point
1998
+ * confidently at the wrong step. Shipping the run's own graph is a bigger slice than #35 asks
1999
+ * for, so it stays a list of steps and the reason is written here rather than lost.
2000
+ *
2001
+ * ⚠️ `getRunVisible` alone opens this, and the current step is deliberately *not* re-authorized
2002
+ * the way `getRun` does it. `getRun` hands out the next thing to do and must therefore ask again
2003
+ * whether this person may still do it; history says what already happened, and a document
2004
+ * withdrawn since must not rewrite the record of a run that read it while it was granted.
2005
+ */
2006
+ async listRunSteps(actor, runId) {
2007
+ const run = await deps.repository.getRunVisible(actor, runId);
2008
+ if (!run)
2009
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
2010
+ const version = await requireVersion(run.versionId, run.flowId);
2011
+ const records = await deps.repository.runSteps(run.id);
2012
+ // Every call this run made, asked once for the whole history rather than once per step (#30).
2013
+ const calls = await visibleCalls(actor, records
2014
+ .filter((record) => nodeFor(version, record.nodeId)?.kind === "subflow")
2015
+ .map((record) => ({ runId: run.id, nodeId: record.nodeId })));
2016
+ const steps = [];
2017
+ for (const record of records) {
2018
+ const node = nodeFor(version, record.nodeId);
2019
+ const label = node?.label ?? record.nodeId;
2020
+ const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
2021
+ const detail = node?.kind === "subflow" ? callDetail(calledRunId, label, record.error) : record.error;
2022
+ steps.push({
2023
+ nodeId: record.nodeId,
2024
+ nodeLabel: label,
2025
+ outcome: record.outcome,
2026
+ branch: record.branch,
2027
+ detail: record.outcome === "failed" ? (detail ?? StepFailedDetail) : detail,
2028
+ calledRunId: calledRunId ?? null,
2029
+ completedAt: record.completedAt,
2030
+ });
2031
+ }
2032
+ return {
2033
+ runId: run.id,
2034
+ flowId: run.flowId,
2035
+ status: run.status,
2036
+ steps,
2037
+ trail: await trailFor(run, version),
2038
+ };
2039
+ },
2040
+ /**
2041
+ * End a run on purpose (#145). Until this existed the only way off a parked manual step was
2042
+ * `completeStep` with `outcome: "failed"`, which recorded a step failure that never happened.
2043
+ * Cancelling writes no step row at all: the run ends, the step history stays true.
2044
+ *
2045
+ * ⚠️ Whoever may start a run may end it: the same Gate capability and the same `execute` the
2046
+ * start asked for, asked again now — a grant revoked since the start must not keep the holder
2047
+ * of an old run in control of it. Deliberately NOT `initiatedBy === actor.id`: a second person
2048
+ * with `execute` on the flow may end a colleague's stuck run, exactly as they could have
2049
+ * started the same run themselves.
2050
+ *
2051
+ * The child runs of a cancelled caller are cancelled with it, in the same write. A caller
2052
+ * standing on a subflow node is waiting for its child by design; ending the caller alone would
2053
+ * leave the child working for a result nobody will ever read.
2054
+ */
2055
+ async cancelRun(actor, input) {
2056
+ requireRun(actor);
2057
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.cancel", input.idempotencyKey);
2058
+ if (replayed) {
2059
+ const run = await deps.repository.getRunVisible(actor, replayed);
2060
+ if (run)
2061
+ return await step(actor, run);
2062
+ }
2063
+ const current = await deps.repository.getRunVisible(actor, input.runId);
2064
+ if (!current)
2065
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
2066
+ await requireExecute(actor, current.flowId);
2067
+ if (current.status !== "running" && current.status !== "queued") {
2068
+ throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
2069
+ }
2070
+ const occurredAt = deps.now().toISOString();
2071
+ const result = await deps.repository.cancelRun({
2072
+ runId: current.id,
2073
+ flowId: current.flowId,
2074
+ actorId: actor.id,
2075
+ occurredAt,
2076
+ idempotencyKey: input.idempotencyKey,
2077
+ auditId: deps.id(),
2078
+ });
2079
+ if (result === "conflict") {
2080
+ throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
2081
+ }
2082
+ // Every durable wait among the ended runs is woken, children included: each workflow reads
2083
+ // its terminal status and finishes now instead of stalling out on its own timeout.
2084
+ for (const cancelledId of result.cancelled) {
2085
+ await deps.runtime.signal(cancelledId);
2086
+ }
2087
+ const version = await requireVersion(current.versionId, current.flowId);
2088
+ // ⚠️ `runState`, not `step`: the write has happened, and asking permission again AFTER a
2089
+ // write is what turned completed work into a 404 for its own caller (#112).
2090
+ return await runState(actor, {
2091
+ ...current,
2092
+ status: "cancelled",
2093
+ currentNodeId: null,
2094
+ updatedAt: occurredAt,
2095
+ completedAt: occurredAt,
2096
+ }, version);
2097
+ },
2098
+ async completeStep(actor, input) {
2099
+ requireRun(actor);
2100
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.complete", input.idempotencyKey);
2101
+ if (replayed) {
2102
+ const run = await deps.repository.getRunVisible(actor, replayed);
2103
+ if (run)
2104
+ return await step(actor, run);
2105
+ }
2106
+ const current = await deps.repository.getRunVisible(actor, input.runId);
2107
+ if (!current)
2108
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
2109
+ // Rechecked here rather than trusted from the start: a grant revoked between two steps must
2110
+ // stop the next one, not only the next run.
2111
+ await requireExecute(actor, current.flowId);
2112
+ if (current.status !== "running") {
2113
+ throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
2114
+ }
2115
+ if (current.currentNodeId !== input.nodeId) {
2116
+ throw new IntelError(409, "flow_step_conflict", "A different step is currently active");
2117
+ }
2118
+ const version = await requireVersion(current.versionId, current.flowId);
2119
+ const node = nodeFor(version, input.nodeId);
2120
+ if (!node) {
2121
+ throw new IntelError(500, "flow_version_corrupt", "Current run node is missing from the immutable version");
2122
+ }
2123
+ // Asked again before the result is recorded, not only when the step was handed out: a grant
2124
+ // revoked while the step was being carried out must stop it from landing.
2125
+ await requireNodeAuthorized(actor, node, version.graph);
2126
+ // ⚠️ A subflow step is finished by the run it started, and by nothing the caller of this
2127
+ // surface says. Outcome, output and error are all read back out of that run: the outcome too,
2128
+ // or a client could mark a call that succeeded as failed and send the flow down a branch the
2129
+ // called flow never asked for. The state of a run belongs to the server.
2130
+ let outcome = input.outcome;
2131
+ let stepOutput = input.output;
2132
+ let stepError = input.error;
2133
+ if (node.kind === "subflow") {
2134
+ const called = await deps.repository.findChildRun(current.id, node.id);
2135
+ if (!called) {
2136
+ throw new IntelError(409, "flow_subflow_not_started", `Start the called flow before completing this step: ${node.label}`);
2137
+ }
2138
+ if (called.status === "completed") {
2139
+ outcome = "completed";
2140
+ stepOutput = called.output;
2141
+ stepError = null;
2142
+ }
2143
+ else if (called.status === "failed" || called.status === "cancelled") {
2144
+ outcome = "failed";
2145
+ stepOutput = null;
2146
+ stepError = called.error ?? calledStepDetail(node.label);
2147
+ }
2148
+ else {
2149
+ throw new IntelError(409, "flow_subflow_running", `The called flow is still running: ${node.label}`);
2150
+ }
2151
+ }
2152
+ const occurredAt = deps.now().toISOString();
2153
+ let status = "running";
2154
+ let currentNodeId = null;
2155
+ let output = current.output;
2156
+ let error = null;
2157
+ let completedAt = null;
2158
+ if (outcome === "failed") {
2159
+ status = "failed";
2160
+ error = stepError ?? StepFailedDetail;
2161
+ completedAt = occurredAt;
2162
+ }
2163
+ else {
2164
+ // ⚠️ Flow edges only, for the same reason as the start above: what a step works with is not
2165
+ // where the run goes next (#37).
2166
+ const edges = version.graph.edges.filter((edge) => edge.kind === "flow" && edge.source === node.id);
2167
+ const edge = node.kind === "condition"
2168
+ ? edges.find((candidate) => candidate.sourceHandle === input.branch)
2169
+ : edges[0];
2170
+ if (!edge) {
2171
+ throw new IntelError(400, "flow_branch_invalid", "A valid branch is required");
2172
+ }
2173
+ // ⚠️ The end is reached, never carried out (D25). It marks where the flow stops and holds
2174
+ // nothing to do, so handing it out as a step would ask the agent to perform an empty node
2175
+ // and then wait for the answer. The run ends here instead, with what the step that just
2176
+ // finished handed in — which is what "the result of a run" now means.
2177
+ if (nodeFor(version, edge.target)?.kind === "output") {
2178
+ status = "completed";
2179
+ output = stepOutput;
2180
+ completedAt = occurredAt;
2181
+ }
2182
+ else {
2183
+ currentNodeId = edge.target;
2184
+ }
2185
+ }
2186
+ const next = {
2187
+ ...current,
2188
+ status,
2189
+ currentNodeId,
2190
+ output,
2191
+ error,
2192
+ updatedAt: occurredAt,
2193
+ completedAt,
2194
+ };
2195
+ const advanced = await deps.repository.advanceRun({
2196
+ run: next,
2197
+ expectedNodeId: input.nodeId,
2198
+ actorId: actor.id,
2199
+ stepId: deps.id(),
2200
+ outcome,
2201
+ branch: input.branch,
2202
+ output: stepOutput,
2203
+ error: stepError,
2204
+ idempotencyKey: input.idempotencyKey,
2205
+ auditId: deps.id(),
2206
+ });
2207
+ if (advanced === "conflict") {
2208
+ throw new IntelError(409, "flow_step_conflict", "Flow step was already completed");
2209
+ }
2210
+ await deps.runtime.signal(next.id);
2211
+ // ⚠️ `runState`, not `step`: the write above has happened. Asking again here is what turned a
2212
+ // completed step into a 404 for the caller (#112).
2213
+ return await runState(actor, next, version);
2214
+ },
2215
+ };
2216
+ }