@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,1194 @@
1
+ import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
2
+ import { BundleImportResult, BundleManifest, BundleManifestFilename, } from "../contract/bundle.js";
3
+ import { FlowGraph } from "../contract/flow.js";
4
+ import { BlockNoteMediaType, DocumentLinkInlineType, } from "../contract/node.js";
5
+ import { TableMediaType } from "../contract/table.js";
6
+ import { documentLinkTargets } from "../nodes/document-links/document-links.js";
7
+ import { parseCsv } from "../shared/csv/csv.js";
8
+ import { IntelError } from "../shared/intel-error/intel-error.js";
9
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
10
+ /**
11
+ * What every bundle leaves out, by decision rather than by accident (#136, ADR-0006). The manifest
12
+ * says so, because a backup that is silent about what it does not hold will be trusted with exactly
13
+ * that.
14
+ *
15
+ * ⚠️ ONE list, read by both halves of the round trip: the export writes it into `manifest.json`,
16
+ * and the import answers with it in `BundleImportResult.excluded` (#433). The import does NOT read
17
+ * the word back out of the manifest it was handed — a bundle claiming `excluded: []` would
18
+ * otherwise make the import report that nothing was left behind, and a naked folder carries no
19
+ * manifest to ask at all. Every one of the five holds for every import this build performs: a node
20
+ * arrives with `sequence: 1` and nothing older, an import mints no grants, it starts no runs,
21
+ * nothing it creates is archived, and a published flow's graph is the one that was published.
22
+ *
23
+ * ⚠️ `unpublished-flow-changes` is a CONSTANT of the build like the other four, not a report about
24
+ * this particular bundle (#585). It says what a bundle never carries — the edits made to a flow
25
+ * after it was published — in the same way `version-history` says so for a node with a single
26
+ * version. A list that named only the losses this one export happens to suffer would be a different
27
+ * kind of statement, and the import could not make it at all: it answers before it knows what is in
28
+ * the zip, and from a naked folder it never finds out.
29
+ */
30
+ const Excluded = [
31
+ "version-history",
32
+ "grants",
33
+ "flow-runs",
34
+ "archived-nodes",
35
+ "unpublished-flow-changes",
36
+ ];
37
+ /**
38
+ * A title as a file name. Titles are free text and zip paths are not: a separator would move the
39
+ * entry, a control character corrupts the archive listing, and a trailing dot breaks extraction on
40
+ * Windows. What cannot be kept is replaced rather than refused — an export must not fail because of
41
+ * how something was named.
42
+ */
43
+ function sanitizeName(title) {
44
+ const cleaned = title
45
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what has to go
46
+ .replace(/[\u0000-\u001f\u007f]/g, "")
47
+ .replace(/[/\\]/g, "-")
48
+ .trim()
49
+ .replace(/^\.+/, "")
50
+ .replace(/[. ]+$/, "")
51
+ .slice(0, 150);
52
+ return cleaned.length > 0 ? cleaned : "untitled";
53
+ }
54
+ // A name that is free in this directory. Case-insensitive, because the zip will be extracted onto
55
+ // filesystems where "Notes.md" and "notes.md" are the same file and the second would overwrite the
56
+ // first without a word.
57
+ function uniqueName(used, base, extension) {
58
+ const candidate = base.toLowerCase().endsWith(extension.toLowerCase())
59
+ ? base.slice(0, base.length - extension.length)
60
+ : base;
61
+ const first = `${candidate}${extension}`;
62
+ if (!used.has(first.toLowerCase())) {
63
+ used.add(first.toLowerCase());
64
+ return first;
65
+ }
66
+ for (let counter = 2;; counter++) {
67
+ const next = `${candidate} (${counter})${extension}`;
68
+ if (!used.has(next.toLowerCase())) {
69
+ used.add(next.toLowerCase());
70
+ return next;
71
+ }
72
+ }
73
+ }
74
+ // ── Import (#137) ───────────────────────────────────────────────────────────────────────────────
75
+ // The import limits, explicit rather than discovered (#137). The zip stream and the unpacked total
76
+ // are both bounded because the Worker holds an entry in memory while it lands: a bundle is parsed
77
+ // where it arrives, and an isolate has 128 MB for everything. The old 15 MB base64 door stays for
78
+ // single attachments; these carry a real bundle several times that size.
79
+ export const MaxImportZipBytes = 64 * 1024 * 1024;
80
+ export const MaxImportEntryBytes = 48 * 1024 * 1024;
81
+ const MaxImportTotalBytes = 96 * 1024 * 1024;
82
+ const MaxImportEntries = 1000;
83
+ // The same key shape `nodes.ts` writes: keys are stored, never derived back, so agreeing on the
84
+ // format costs nothing while a second format would split the bucket for no reason.
85
+ function contentKeyFor(nodeId, versionId) {
86
+ return `nodes/${nodeId}/versions/${versionId}`;
87
+ }
88
+ // Files archivers add that nobody filed: importing them would put macOS bookkeeping into the tree.
89
+ const JunkNames = new Set([".DS_Store", "Thumbs.db"]);
90
+ function isJunkPath(path) {
91
+ const segments = path.replace(/\/$/, "").split("/");
92
+ return segments[0] === "__MACOSX" || JunkNames.has(segments[segments.length - 1] ?? "");
93
+ }
94
+ // A zip entry name is attacker-controlled text, and "../" in one is how an archive writes outside
95
+ // its directory. Nothing here touches a filesystem, but a traversal segment would still corrupt
96
+ // the parent lookup, so the path is refused rather than interpreted.
97
+ function requireSafePath(path) {
98
+ if (path.includes("\\") || path.startsWith("/")) {
99
+ throw new IntelError(400, "import_invalid_path", `Bundle path is not usable: ${path}`);
100
+ }
101
+ for (const segment of path.replace(/\/$/, "").split("/")) {
102
+ if (segment === "" || segment === "." || segment === "..") {
103
+ throw new IntelError(400, "import_invalid_path", `Bundle path is not usable: ${path}`);
104
+ }
105
+ }
106
+ }
107
+ function concatChunks(chunks, size) {
108
+ const joined = new Uint8Array(size);
109
+ let offset = 0;
110
+ for (const chunk of chunks) {
111
+ joined.set(chunk, offset);
112
+ offset += chunk.length;
113
+ }
114
+ return joined;
115
+ }
116
+ /**
117
+ * The whole zip, read incrementally: the compressed stream is fed chunk by chunk and every entry
118
+ * is collected as it completes, so the peak cost is the unpacked entries — bounded above — and
119
+ * never compressed-plus-unpacked at once.
120
+ *
121
+ * ⚠️ A truncated archive does not fail loudly in fflate: entries simply never finish. The open
122
+ * counter turns that silence into the refusal it is — a bundle that ends mid-entry imports
123
+ * nothing (#137, Abbruch-Test).
124
+ */
125
+ async function readZip(source) {
126
+ const files = new Map();
127
+ const directories = new Set();
128
+ let failure = null;
129
+ let unpackedTotal = 0;
130
+ let entryCount = 0;
131
+ let openEntries = 0;
132
+ const invalid = () => new IntelError(400, "import_invalid_bundle", "The zip archive could not be read");
133
+ const unzip = new Unzip((file) => {
134
+ if (failure)
135
+ return;
136
+ const name = file.name;
137
+ if (name.endsWith("/")) {
138
+ directories.add(name);
139
+ return;
140
+ }
141
+ entryCount += 1;
142
+ if (entryCount > MaxImportEntries) {
143
+ failure = new IntelError(413, "import_too_many_entries", `The bundle exceeds ${MaxImportEntries} entries`);
144
+ return;
145
+ }
146
+ openEntries += 1;
147
+ const chunks = [];
148
+ let size = 0;
149
+ file.ondata = (error, chunk, final) => {
150
+ if (failure)
151
+ return;
152
+ if (error) {
153
+ failure = invalid();
154
+ return;
155
+ }
156
+ if (chunk) {
157
+ size += chunk.length;
158
+ unpackedTotal += chunk.length;
159
+ if (size > MaxImportEntryBytes) {
160
+ failure = new IntelError(413, "import_entry_too_large", `A bundle entry exceeds ${MaxImportEntryBytes} bytes unpacked: ${name}`);
161
+ return;
162
+ }
163
+ if (unpackedTotal > MaxImportTotalBytes) {
164
+ failure = new IntelError(413, "import_too_large", `The bundle exceeds ${MaxImportTotalBytes} bytes unpacked`);
165
+ return;
166
+ }
167
+ chunks.push(chunk);
168
+ }
169
+ if (final) {
170
+ openEntries -= 1;
171
+ files.set(name, concatChunks(chunks, size));
172
+ }
173
+ };
174
+ try {
175
+ file.start();
176
+ }
177
+ catch {
178
+ failure = invalid();
179
+ }
180
+ });
181
+ unzip.register(UnzipInflate);
182
+ const push = (chunk, final) => {
183
+ try {
184
+ unzip.push(chunk, final);
185
+ }
186
+ catch {
187
+ failure = failure ?? invalid();
188
+ }
189
+ };
190
+ if (source instanceof Uint8Array) {
191
+ if (source.length > MaxImportZipBytes) {
192
+ throw new IntelError(413, "import_too_large", `The zip exceeds ${MaxImportZipBytes} bytes`);
193
+ }
194
+ push(source, true);
195
+ }
196
+ else {
197
+ const reader = source.getReader();
198
+ let compressed = 0;
199
+ for (;;) {
200
+ const { done, value } = await reader.read();
201
+ if (done)
202
+ break;
203
+ compressed += value.length;
204
+ if (compressed > MaxImportZipBytes) {
205
+ throw new IntelError(413, "import_too_large", `The zip exceeds ${MaxImportZipBytes} bytes`);
206
+ }
207
+ push(value, false);
208
+ if (failure)
209
+ throw failure;
210
+ }
211
+ push(new Uint8Array(0), true);
212
+ }
213
+ if (failure)
214
+ throw failure;
215
+ if (openEntries > 0 || (files.size === 0 && directories.size === 0))
216
+ throw invalid();
217
+ return { files, directories };
218
+ }
219
+ const decoder = new TextDecoder("utf-8", { fatal: true });
220
+ function decodeText(body, path) {
221
+ try {
222
+ return decoder.decode(body);
223
+ }
224
+ catch {
225
+ throw new IntelError(400, "import_invalid_text", `Bundle entry is not UTF-8: ${path}`);
226
+ }
227
+ }
228
+ function depthOf(path) {
229
+ return path.replace(/\/$/, "").split("/").length;
230
+ }
231
+ function directoryOf(path) {
232
+ const trimmed = path.replace(/\/$/, "");
233
+ const cut = trimmed.lastIndexOf("/");
234
+ return cut === -1 ? "" : trimmed.slice(0, cut + 1);
235
+ }
236
+ // What a file extension says a naked upload is (#137): the three kinds a name can carry, and
237
+ // attachment for everything else. Only used without a manifest — a manifest already says.
238
+ function nakedKindOf(name) {
239
+ const lower = name.toLowerCase();
240
+ if (lower.endsWith(".md")) {
241
+ return { kind: "document", mediaType: "text/markdown", title: name.slice(0, -3) };
242
+ }
243
+ if (lower.endsWith(".csv")) {
244
+ return { kind: "table", mediaType: TableMediaType, title: name.slice(0, -4) };
245
+ }
246
+ // ⚠️ `.board.json` had its own rule here until #390 and deliberately does not any more. This is
247
+ // the NAKED-folder path — a directory somebody picked, with no manifest — so a file called
248
+ // `plan.board.json` is a JSON file on a disk, and keeping it as an attachment preserves it. A
249
+ // real export is a different matter: its manifest declares the kind, and that is where such an
250
+ // entry is refused by name.
251
+ return { kind: "attachment", mediaType: guessedMediaType(lower), title: name };
252
+ }
253
+ const KnownMediaTypes = {
254
+ ".pdf": "application/pdf",
255
+ ".png": "image/png",
256
+ ".jpg": "image/jpeg",
257
+ ".jpeg": "image/jpeg",
258
+ ".gif": "image/gif",
259
+ ".svg": "image/svg+xml",
260
+ ".txt": "text/plain",
261
+ ".json": "application/json",
262
+ ".zip": "application/zip",
263
+ };
264
+ function guessedMediaType(lowerName) {
265
+ const dot = lowerName.lastIndexOf(".");
266
+ return ((dot === -1 ? undefined : KnownMediaTypes[lowerName.slice(dot)]) ?? "application/octet-stream");
267
+ }
268
+ function isRecord(value) {
269
+ return typeof value === "object" && value !== null;
270
+ }
271
+ // The link rewrite of #137: every `documentLink` inline element whose target the bundle carried is
272
+ // pointed at the node the import just made; a link to a node outside the bundle stays as written.
273
+ // Tolerant like `documentLinkTargets`: what is not recognised is carried over unchanged.
274
+ function remapDocumentLinks(content, idMap) {
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(content);
278
+ }
279
+ catch {
280
+ return content;
281
+ }
282
+ function walk(value) {
283
+ if (Array.isArray(value)) {
284
+ for (const entry of value)
285
+ walk(entry);
286
+ return;
287
+ }
288
+ if (!isRecord(value))
289
+ return;
290
+ if (value.type === DocumentLinkInlineType && isRecord(value.props)) {
291
+ const target = value.props.nodeId;
292
+ if (typeof target === "string" && idMap.has(target)) {
293
+ value.props.nodeId = idMap.get(target);
294
+ }
295
+ }
296
+ // `blocks` is the document's own top level; `content` and `children` are where a block hides
297
+ // inline elements — the same three places `documentLinkTargets` reads.
298
+ walk(value.blocks);
299
+ walk(value.content);
300
+ walk(value.children);
301
+ }
302
+ walk(parsed);
303
+ return JSON.stringify(parsed);
304
+ }
305
+ /**
306
+ * The graph rewrite of #137: link nodes point at the imported copies of what they named, a subflow
307
+ * calls the imported copy of its callee. References to things outside the bundle stay untouched.
308
+ *
309
+ * ⚠️ A `pinned` sub-flow version cannot survive the remap of its callee: the pinned ID names a row
310
+ * of the OLD installation's history, which this import deliberately does not recreate. The call
311
+ * falls back to `latest` — the draft default — and publishing will pin it anew.
312
+ */
313
+ function remapFlowGraph(graph, idMap) {
314
+ return {
315
+ ...graph,
316
+ nodes: graph.nodes.map((node) => {
317
+ if ((node.kind === "folder" ||
318
+ node.kind === "document" ||
319
+ node.kind === "upload" ||
320
+ node.kind === "table") &&
321
+ idMap.has(node.configuration.resourceId)) {
322
+ return {
323
+ ...node,
324
+ configuration: {
325
+ ...node.configuration,
326
+ resourceId: idMap.get(node.configuration.resourceId),
327
+ },
328
+ };
329
+ }
330
+ if (node.kind === "subflow" && idMap.has(node.configuration.flowId)) {
331
+ return {
332
+ ...node,
333
+ configuration: {
334
+ flowId: idMap.get(node.configuration.flowId),
335
+ version: node.configuration.version.mode === "pinned"
336
+ ? { mode: "latest" }
337
+ : node.configuration.version,
338
+ },
339
+ };
340
+ }
341
+ return node;
342
+ }),
343
+ };
344
+ }
345
+ function flowGraphJson(version) {
346
+ // A flow that was never saved has no graph; its entry is an empty file rather than an invented
347
+ // graph, and the import creates the same empty flow back.
348
+ return version === undefined ? "" : JSON.stringify(version.graph, null, 2);
349
+ }
350
+ export function createBundle(deps) {
351
+ // Flow visibility is the flow repository's question and takes its actor shape. `canRun` gates
352
+ // starting runs, which no export does, so it is answered with the conservative constant.
353
+ function asFlowActor(actor) {
354
+ return {
355
+ id: actor.id,
356
+ email: actor.email,
357
+ canRun: false,
358
+ ...(actor.isAdmin === undefined ? {} : { isAdmin: actor.isAdmin }),
359
+ };
360
+ }
361
+ async function tableCsv(nodeId) {
362
+ const keys = await deps.repository.listVersionContentKeys(nodeId);
363
+ // ⚠️ `some` on an empty list is false, so no segments at all used to walk past the guard below
364
+ // and return `[].join("")` — the empty string, written into the zip as a file of zero bytes
365
+ // (#534). A table that has never been defined no longer reaches this function at all: it has no
366
+ // current version, and `plannedNode` marks it absent instead. An empty list HERE therefore means
367
+ // something else — a current version whose segments are gone — which is the loss the guard below
368
+ // already names, and it deserves the same answer rather than a silently empty file.
369
+ if (keys.length === 0) {
370
+ throw new IntelError(500, "content_missing", "Version content is missing");
371
+ }
372
+ const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
373
+ if (segments.some((segment) => segment === null)) {
374
+ throw new IntelError(500, "content_missing", "Version content is missing");
375
+ }
376
+ return segments.join("");
377
+ }
378
+ function textLoader(contentKey) {
379
+ return async () => {
380
+ const body = await deps.content.get(contentKey);
381
+ if (body === null) {
382
+ throw new IntelError(500, "content_missing", "Version content is missing");
383
+ }
384
+ return body;
385
+ };
386
+ }
387
+ /**
388
+ * The bytes of an attachment version, or the same refusal the text path has always given (#560).
389
+ *
390
+ * ⚠️ A `contentKey` in the row and nothing behind it in R2 is data LOSS, and the attachment path
391
+ * was the one path that answered it with silence: `getStream` returned `null`, the zip got a file
392
+ * of zero bytes, and the export reported success. Whoever filed that zip away as a backup learns
393
+ * what it holds when they restore it — the one moment nothing can be done about it any more.
394
+ *
395
+ * This is deliberately NOT the `absence` answer. "Never uploaded" is a state of the node and
396
+ * travels in the manifest; "the object is gone" is a broken installation, and a bundle must not
397
+ * be able to describe it as if it were fine. Same distinction as `textLoader` and `tableCsv`.
398
+ */
399
+ function streamLoader(contentKey) {
400
+ return async () => {
401
+ const stream = await deps.content.getStream(contentKey);
402
+ if (stream === null) {
403
+ throw new IntelError(500, "content_missing", "Version content is missing");
404
+ }
405
+ return stream;
406
+ };
407
+ }
408
+ function plannedNode(row, directory, used) {
409
+ const { node, version } = row;
410
+ const base = sanitizeName(node.title);
411
+ if (node.kind === "folder") {
412
+ const name = uniqueName(used, base, "");
413
+ const path = `${directory}${name}/`;
414
+ return {
415
+ manifest: {
416
+ id: node.id,
417
+ kind: "folder",
418
+ title: node.title,
419
+ description: node.description,
420
+ mediaType: null,
421
+ path,
422
+ },
423
+ content: { type: "folder" },
424
+ };
425
+ }
426
+ if (node.kind === "table") {
427
+ const path = `${directory}${uniqueName(used, base, ".csv")}`;
428
+ // ⚠️ A table with no current version has never been given a header, and for a table the
429
+ // canonical content IS the CSV: there is nothing to write. It used to travel as a file of zero
430
+ // bytes, which the holder of the zip cannot tell apart from a table whose content was lost on
431
+ // the way — the very case `tableCsv` refuses as `content_missing`. One file, two meanings, no
432
+ // way to read which (#534).
433
+ //
434
+ // ⚠️ The entry STAYS in the manifest, and that is the half that matters: dropping it would
435
+ // take the node's title, its description and its place in the tree out of the backup too, and
436
+ // the import would recreate a subtree with a node missing from it without anybody noticing.
437
+ // What travels instead is the node without bytes, plus the word for what is not there.
438
+ if (version === null) {
439
+ return {
440
+ manifest: {
441
+ id: node.id,
442
+ kind: "table",
443
+ title: node.title,
444
+ description: node.description,
445
+ mediaType: TableMediaType,
446
+ path,
447
+ absence: "no-content",
448
+ },
449
+ content: { type: "absent" },
450
+ };
451
+ }
452
+ return {
453
+ manifest: {
454
+ id: node.id,
455
+ kind: "table",
456
+ title: node.title,
457
+ description: node.description,
458
+ mediaType: TableMediaType,
459
+ path,
460
+ },
461
+ content: { type: "text", load: async () => await tableCsv(node.id) },
462
+ };
463
+ }
464
+ if (node.kind === "attachment") {
465
+ // Original bytes under the original name: an attachment's title IS its file name, so no
466
+ // extension is imposed on it.
467
+ const path = `${directory}${uniqueName(used, base, "")}`;
468
+ // ⚠️ An attachment with no current version was created and never uploaded to, and for an
469
+ // attachment the canonical content IS the bytes: there is nothing to write. It used to travel
470
+ // as a file of zero bytes — the same one file with two meanings the table branch above
471
+ // refuses, and here the second meaning is a lost R2 object (#560).
472
+ //
473
+ // ⚠️ The entry STAYS in the manifest for the same reason it does for a table: dropping it
474
+ // would take the node's title, its description and its place in the tree out of the backup,
475
+ // and the import would rebuild a subtree with a node missing from it and say nothing.
476
+ if (version === null) {
477
+ return {
478
+ manifest: {
479
+ id: node.id,
480
+ kind: "attachment",
481
+ title: node.title,
482
+ description: node.description,
483
+ mediaType: "application/octet-stream",
484
+ path,
485
+ absence: "no-content",
486
+ },
487
+ content: { type: "absent" },
488
+ };
489
+ }
490
+ return {
491
+ manifest: {
492
+ id: node.id,
493
+ kind: "attachment",
494
+ title: node.title,
495
+ description: node.description,
496
+ mediaType: version.mediaType ?? "application/octet-stream",
497
+ path,
498
+ },
499
+ content: { type: "stream", load: streamLoader(version.contentKey) },
500
+ };
501
+ }
502
+ // ⚠️ A row of a kind this build no longer makes — an `agent` or a `board` written before #390
503
+ // — falls through to the document branch and travels as its raw body. Deliberately forgiving:
504
+ // refusing here would make one leftover row enough to stop a whole subtree from being exported,
505
+ // and the file it produces is exactly the bytes that were stored. The IMPORT is where such a
506
+ // bundle is turned away, by name.
507
+ // A document: markdown stays `.md`, a BlockNote body is JSON and says so with its name.
508
+ const mediaType = version?.mediaType ?? "text/markdown";
509
+ const extension = mediaType === BlockNoteMediaType ? ".json" : ".md";
510
+ const path = `${directory}${uniqueName(used, base, extension)}`;
511
+ return {
512
+ manifest: {
513
+ id: node.id,
514
+ kind: "document",
515
+ title: node.title,
516
+ description: node.description,
517
+ mediaType,
518
+ path,
519
+ },
520
+ content: version === null
521
+ ? { type: "text", load: async () => "" }
522
+ : { type: "text", load: textLoader(version.contentKey) },
523
+ };
524
+ }
525
+ /**
526
+ * Which graph of a flow travels: the PUBLISHED one when the flow has one, the draft otherwise.
527
+ * Three call sites read it through this function rather than spelling the fallback out, because
528
+ * it is one decision and not three (#585).
529
+ *
530
+ * ⚠️ For a flow that was published and edited afterwards, this deliberately leaves the newer draft
531
+ * behind — and that is NOT the `version-history` case, which is why `Excluded` names it with a
532
+ * word of its own. The draft is the newest state there is; what decides against it is that the
533
+ * import cannot carry both. It creates rather than restores (ADR-0006): every flow arrives as a
534
+ * draft with one version and `publishedVersionId: null`, so "published v1 plus draft v2" has no
535
+ * shape on the far side, and carrying both would be flow version history — the thing that ADR
536
+ * refuses for exactly the reasons it lists.
537
+ *
538
+ * Of the two single answers the published one wins because it is the one that was IN EFFECT. A
539
+ * draft need never have been validated — publishing is what checks a graph — so moving the draft
540
+ * instead would replace a running process with somebody's unfinished edit, silently, at the one
541
+ * moment nobody can compare against the source any more.
542
+ */
543
+ function exportedVersionId(flow) {
544
+ return flow.publishedVersionId ?? flow.currentVersionId;
545
+ }
546
+ function plannedFlow(flow, version, directory, used) {
547
+ const path = `${directory}${uniqueName(used, sanitizeName(flow.title), ".json")}`;
548
+ return {
549
+ manifest: {
550
+ id: flow.id,
551
+ kind: "flow",
552
+ title: flow.title,
553
+ description: flow.description,
554
+ mediaType: "application/json",
555
+ path,
556
+ },
557
+ content: { type: "text", load: async () => flowGraphJson(version) },
558
+ };
559
+ }
560
+ /**
561
+ * The plan of one export: every entry with its path, parents before children.
562
+ *
563
+ * The tree is walked from the chosen root through what `listVisibleSubtree` answered — one
564
+ * statement carrying the same `allowed` predicate every other read uses, so a node without read
565
+ * is neither in the zip nor mentioned in the manifest (#136). Flows ride along by the folder they
566
+ * are filed in, only when the caller holds `flows/read` at all, and their graph is the published
567
+ * version when there is one — the frozen state — falling back to the current draft.
568
+ */
569
+ async function plan(actor, rootId) {
570
+ if (rootId !== null) {
571
+ const root = await deps.repository.getVisible(actor, rootId);
572
+ // An archived node is out of every bundle by decision, and "archived" must not be
573
+ // distinguishable from "absent" through this door either.
574
+ if (!root || root.archivedAt !== null) {
575
+ throw new IntelError(404, "node_not_found", "Node was not found");
576
+ }
577
+ }
578
+ const rows = await deps.repository.listVisibleSubtree(actor, rootId);
579
+ const byParent = new Map();
580
+ const included = new Set(rows.map((row) => row.node.id));
581
+ for (const row of rows) {
582
+ // The export root's own parent is outside the bundle; it anchors at the zip root.
583
+ const key = row.node.id === rootId || !row.node.parentId || !included.has(row.node.parentId)
584
+ ? null
585
+ : row.node.parentId;
586
+ const level = byParent.get(key) ?? [];
587
+ level.push(row);
588
+ byParent.set(key, level);
589
+ }
590
+ const flowsByParent = new Map();
591
+ const flowVersions = new Map();
592
+ if (actor.canReadFlows) {
593
+ const folderIds = new Set(rows.filter((row) => row.node.kind === "folder").map((row) => row.node.id));
594
+ const visible = (await deps.flows.listVisible(asFlowActor(actor))).filter((flow) => flow.parentId === null ? rootId === null : folderIds.has(flow.parentId));
595
+ const versionIds = visible.map(exportedVersionId).filter((id) => id !== null);
596
+ for (const version of await deps.flows.getVersions(versionIds)) {
597
+ flowVersions.set(version.id, version);
598
+ }
599
+ for (const flow of visible) {
600
+ const level = flowsByParent.get(flow.parentId) ?? [];
601
+ level.push(flow);
602
+ flowsByParent.set(flow.parentId, level);
603
+ }
604
+ }
605
+ const entries = [];
606
+ // Folders first, then titles, the order the tree itself draws — so the zip listing reads like
607
+ // the sidebar. Ties fall back to the id to stay deterministic.
608
+ function walk(parentKey, directory) {
609
+ const level = [
610
+ ...(byParent.get(parentKey) ?? []).map((row) => ({ row, flow: null })),
611
+ ...(flowsByParent.get(parentKey) ?? []).map((flow) => ({
612
+ row: null,
613
+ flow,
614
+ })),
615
+ ].sort((left, right) => {
616
+ const leftFolder = left.row?.node.kind === "folder" ? 0 : 1;
617
+ const rightFolder = right.row?.node.kind === "folder" ? 0 : 1;
618
+ const leftTitle = left.row?.node.title ?? left.flow?.title ?? "";
619
+ const rightTitle = right.row?.node.title ?? right.flow?.title ?? "";
620
+ const leftId = left.row?.node.id ?? left.flow?.id ?? "";
621
+ const rightId = right.row?.node.id ?? right.flow?.id ?? "";
622
+ return (leftFolder - rightFolder ||
623
+ leftTitle.toLowerCase().localeCompare(rightTitle.toLowerCase()) ||
624
+ leftId.localeCompare(rightId));
625
+ });
626
+ const used = new Set();
627
+ for (const item of level) {
628
+ if (item.row !== null) {
629
+ const entry = plannedNode(item.row, directory, used);
630
+ entries.push(entry);
631
+ if (item.row.node.kind === "folder") {
632
+ walk(item.row.node.id, entry.manifest.path);
633
+ }
634
+ continue;
635
+ }
636
+ if (item.flow !== null) {
637
+ const versionId = exportedVersionId(item.flow);
638
+ entries.push(plannedFlow(item.flow, versionId === null ? undefined : flowVersions.get(versionId), directory, used));
639
+ }
640
+ }
641
+ }
642
+ walk(null, "");
643
+ return entries;
644
+ }
645
+ function manifestOf(rootId, entries) {
646
+ return {
647
+ version: 1,
648
+ exportedAt: deps.now().toISOString(),
649
+ rootId,
650
+ entries: entries.map((entry) => entry.manifest),
651
+ excluded: Excluded,
652
+ };
653
+ }
654
+ /**
655
+ * The zip as a stream. fflate writes into a TransformStream whose readable side is the response
656
+ * body; the pump pushes one entry at a time and waits for `writer.ready` between chunks, so the
657
+ * memory in flight stays one chunk deep however large an attachment is — the whole point of
658
+ * streaming rather than assembling (#136).
659
+ */
660
+ function zipStream(manifest, entries) {
661
+ const { readable, writable } = new TransformStream();
662
+ const writer = writable.getWriter();
663
+ const zip = new Zip((error, chunk, final) => {
664
+ if (error) {
665
+ void writer.abort(error).catch(() => undefined);
666
+ return;
667
+ }
668
+ void writer.write(chunk).catch(() => undefined);
669
+ if (final)
670
+ void writer.close().catch(() => undefined);
671
+ });
672
+ const encoder = new TextEncoder();
673
+ async function pushText(name, text) {
674
+ // Text bodies deflate well and are bounded by the save limit; attachments go through the
675
+ // store path below instead, because recompressing archives and images buys nothing.
676
+ const file = new ZipDeflate(name, { level: 6 });
677
+ zip.add(file);
678
+ file.push(encoder.encode(text), true);
679
+ await writer.ready;
680
+ }
681
+ async function pump() {
682
+ // The manifest goes first so a streaming reader — the import — knows what it is walking into
683
+ // before the first content byte.
684
+ await pushText(BundleManifestFilename, JSON.stringify(manifest, null, 2));
685
+ for (const entry of entries) {
686
+ if (entry.content.type === "folder") {
687
+ const folder = new ZipPassThrough(entry.manifest.path);
688
+ zip.add(folder);
689
+ folder.push(new Uint8Array(0), true);
690
+ await writer.ready;
691
+ continue;
692
+ }
693
+ // Nothing is written for it — not even an empty file, which is the whole point (#534). The
694
+ // manifest carries the node and says why its path is unoccupied.
695
+ if (entry.content.type === "absent")
696
+ continue;
697
+ if (entry.content.type === "text") {
698
+ await pushText(entry.manifest.path, await entry.content.load());
699
+ continue;
700
+ }
701
+ // ⚠️ No `null` branch here any more, and its removal is the repair (#560): it wrote a file
702
+ // of zero bytes for "the loader has nothing", which was true both for an attachment nobody
703
+ // ever uploaded to and for one whose object had left R2. The first is now `absent` above
704
+ // and never reaches this loop; the second throws out of `load()` and aborts the whole zip,
705
+ // which is what a backup that cannot be written is supposed to do.
706
+ //
707
+ // The loader runs BEFORE the entry is added, so a refusal leaves no half-opened file
708
+ // behind in the zip that nothing will ever terminate.
709
+ const stream = await entry.content.load();
710
+ const file = new ZipPassThrough(entry.manifest.path);
711
+ zip.add(file);
712
+ const reader = stream.getReader();
713
+ for (;;) {
714
+ const { done, value } = await reader.read();
715
+ if (done)
716
+ break;
717
+ file.push(value);
718
+ await writer.ready;
719
+ }
720
+ file.push(new Uint8Array(0), true);
721
+ await writer.ready;
722
+ }
723
+ zip.end();
724
+ }
725
+ pump().catch((error) => {
726
+ void writer.abort(error).catch(() => undefined);
727
+ });
728
+ return readable;
729
+ }
730
+ // The manifest, when the zip carries one. A file that merely wears the name — a naked folder
731
+ // with somebody's own manifest.json in it — is left to the naked path; a file that clearly
732
+ // means to be a bundle manifest but does not parse is a broken bundle and refuses the import.
733
+ function readImportManifest(files) {
734
+ const raw = files.get(BundleManifestFilename);
735
+ if (raw === undefined)
736
+ return { manifest: null, text: null };
737
+ let json;
738
+ let text;
739
+ try {
740
+ text = decodeText(raw, BundleManifestFilename);
741
+ json = JSON.parse(text);
742
+ }
743
+ catch {
744
+ return { manifest: null, text: null };
745
+ }
746
+ if (!isRecord(json) || json.version === undefined || json.entries === undefined) {
747
+ return { manifest: null, text: null };
748
+ }
749
+ const parsed = BundleManifest.safeParse(json);
750
+ if (!parsed.success) {
751
+ throw new IntelError(400, "import_invalid_manifest", "manifest.json does not match the bundle manifest contract");
752
+ }
753
+ return { manifest: parsed.data, text };
754
+ }
755
+ // The plan a manifest dictates: kinds and titles from its entries, hierarchy from its paths.
756
+ // Sorted by path depth so a parent folder's new ID exists before its children ask for it — the
757
+ // same order the atomic insert needs (see `importTree`).
758
+ function importPlanFromManifest(manifest, files) {
759
+ const sorted = [...manifest.entries].sort((left, right) => depthOf(left.path) - depthOf(right.path));
760
+ const folderIdByPath = new Map();
761
+ const entries = [];
762
+ for (const entry of sorted) {
763
+ requireSafePath(entry.path);
764
+ const isFolder = entry.kind === "folder";
765
+ const parentPath = directoryOf(entry.path);
766
+ const parentNewId = parentPath === "" ? null : (folderIdByPath.get(parentPath) ?? null);
767
+ if (parentPath !== "" && parentNewId === null) {
768
+ throw new IntelError(400, "import_bundle_incomplete", `The manifest names no folder for: ${entry.path}`);
769
+ }
770
+ const newId = deps.id();
771
+ if (isFolder)
772
+ folderIdByPath.set(entry.path.endsWith("/") ? entry.path : `${entry.path}/`, newId);
773
+ let body = null;
774
+ // ⚠️ An entry that declares an `absence` names a path with nothing at it ON PURPOSE, so the
775
+ // zip is complete rather than broken (#534). Without this the export written by this very
776
+ // build would refuse its own import — and the node it describes arrives the way it left: a
777
+ // table with a title, a place in the tree and no content.
778
+ if (!isFolder && entry.absence === undefined) {
779
+ const found = files.get(entry.path);
780
+ if (found === undefined) {
781
+ throw new IntelError(400, "import_bundle_incomplete", `The manifest names a file the zip does not carry: ${entry.path}`);
782
+ }
783
+ body = found;
784
+ }
785
+ entries.push({
786
+ kind: entry.kind,
787
+ oldId: entry.id,
788
+ newId,
789
+ parentNewId,
790
+ title: entry.title,
791
+ description: entry.description,
792
+ mediaType: entry.mediaType,
793
+ body,
794
+ path: entry.path,
795
+ });
796
+ }
797
+ return entries;
798
+ }
799
+ // The plan a naked folder allows: hierarchy from the paths, kinds from the extensions (#137),
800
+ // no IDs and therefore nothing to remap. Directories a zip only implies — a file three levels
801
+ // deep without its folders spelled out — are created all the same.
802
+ function importPlanNaked(contents) {
803
+ const folderPaths = new Set();
804
+ const filePaths = [];
805
+ for (const directory of contents.directories) {
806
+ if (isJunkPath(directory))
807
+ continue;
808
+ requireSafePath(directory);
809
+ folderPaths.add(directory.endsWith("/") ? directory : `${directory}/`);
810
+ }
811
+ for (const path of contents.files.keys()) {
812
+ if (isJunkPath(path))
813
+ continue;
814
+ requireSafePath(path);
815
+ filePaths.push(path);
816
+ }
817
+ for (const path of [...folderPaths, ...filePaths]) {
818
+ const segments = path.replace(/\/$/, "").split("/");
819
+ for (let depth = 1; depth < segments.length; depth++) {
820
+ folderPaths.add(`${segments.slice(0, depth).join("/")}/`);
821
+ }
822
+ }
823
+ const folderIdByPath = new Map();
824
+ const entries = [];
825
+ for (const path of [...folderPaths].sort((left, right) => depthOf(left) - depthOf(right))) {
826
+ const newId = deps.id();
827
+ folderIdByPath.set(path, newId);
828
+ const segments = path.replace(/\/$/, "").split("/");
829
+ entries.push({
830
+ kind: "folder",
831
+ oldId: null,
832
+ newId,
833
+ parentNewId: segments.length === 1
834
+ ? null
835
+ : folderIdByPath.get(`${segments.slice(0, -1).join("/")}/`),
836
+ title: segments[segments.length - 1],
837
+ description: null,
838
+ mediaType: null,
839
+ body: null,
840
+ path,
841
+ });
842
+ }
843
+ for (const path of filePaths.sort((left, right) => depthOf(left) - depthOf(right))) {
844
+ const segments = path.split("/");
845
+ const name = segments[segments.length - 1];
846
+ const derived = nakedKindOf(name);
847
+ entries.push({
848
+ kind: derived.kind,
849
+ oldId: null,
850
+ newId: deps.id(),
851
+ parentNewId: segments.length === 1
852
+ ? null
853
+ : folderIdByPath.get(`${segments.slice(0, -1).join("/")}/`),
854
+ title: derived.title.length > 0 ? derived.title.slice(0, 240) : name.slice(0, 240),
855
+ description: null,
856
+ mediaType: derived.mediaType,
857
+ body: contents.files.get(path) ?? null,
858
+ path,
859
+ });
860
+ }
861
+ return entries;
862
+ }
863
+ function replayResult(metadata) {
864
+ return BundleImportResult.parse({
865
+ nodes: typeof metadata.nodes === "number" ? metadata.nodes : 0,
866
+ flows: typeof metadata.flows === "number" ? metadata.flows : 0,
867
+ rootNodeIds: Array.isArray(metadata.rootNodeIds) ? metadata.rootNodeIds : [],
868
+ replayed: true,
869
+ // Not read out of the stored metadata: a replay answers for an import this build performed,
870
+ // and the list is what this build leaves behind rather than something an earlier row said.
871
+ excluded: Excluded,
872
+ });
873
+ }
874
+ return {
875
+ async exportSubtree(actor, rootId) {
876
+ const entries = await plan(actor, rootId);
877
+ const manifest = manifestOf(rootId, entries);
878
+ const rootEntry = rootId === null ? null : entries.find((entry) => entry.manifest.id === rootId);
879
+ const filename = rootEntry === null || rootEntry === undefined
880
+ ? "intel-export.zip"
881
+ : `${sanitizeName(rootEntry.manifest.title)}.zip`;
882
+ return { filename, stream: zipStream(manifest, entries) };
883
+ },
884
+ async exportFlow(actor, flowId) {
885
+ const flow = await deps.flows.getVisible(asFlowActor(actor), flowId);
886
+ if (!flow || flow.archivedAt !== null) {
887
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
888
+ }
889
+ const versionId = exportedVersionId(flow);
890
+ const version = versionId === null ? undefined : (await deps.flows.getVersions([versionId]))[0];
891
+ const entries = [plannedFlow(flow, version, "", new Set())];
892
+ // A single flow's bundle is rooted at the flow itself: the manifest names it as the root the
893
+ // same way a single node export does.
894
+ const manifest = manifestOf(flow.id, entries);
895
+ return { filename: `${sanitizeName(flow.title)}.zip`, stream: zipStream(manifest, entries) };
896
+ },
897
+ async manifest(actor, rootId) {
898
+ return manifestOf(rootId, await plan(actor, rootId));
899
+ },
900
+ /**
901
+ * One bundle in, one new subtree out (#137, phase 1): always new nodes, never a merge, never
902
+ * an overwrite — a second import of the same bundle is a second subtree, and only the
903
+ * idempotency key makes a RETRY of the same request not be one.
904
+ *
905
+ * The order of operations is the whole safety story: authorize, parse and decide everything,
906
+ * write the R2 bodies, then land every row in ONE `db.batch`. The batch is a transaction, so a
907
+ * half-imported tree cannot exist; R2 objects written for a batch that refused are deleted
908
+ * again, exactly as `save` treats its single object.
909
+ */
910
+ async importBundle(actor, input) {
911
+ const earlier = await deps.repository.findImportReplay(actor.id, input.idempotencyKey);
912
+ if (earlier !== null)
913
+ return replayResult(earlier);
914
+ if (input.targetNodeId !== null) {
915
+ const target = await deps.repository.getVisible(actor, input.targetNodeId);
916
+ if (!target || target.archivedAt !== null) {
917
+ throw new IntelError(404, "node_not_found", "Node was not found");
918
+ }
919
+ if (target.kind !== "folder") {
920
+ throw new IntelError(409, "import_target_not_folder", "Imports land in a folder");
921
+ }
922
+ if (!(await deps.repository.can(actor, target.id, "write"))) {
923
+ throw new IntelError(403, "node_forbidden", "Target folder cannot be edited");
924
+ }
925
+ }
926
+ const contents = await readZip(input.zip);
927
+ const { manifest, text: manifestText } = readImportManifest(contents.files);
928
+ if (manifest !== null)
929
+ contents.files.delete(BundleManifestFilename);
930
+ const entries = manifest !== null
931
+ ? importPlanFromManifest(manifest, contents.files)
932
+ : importPlanNaked(contents);
933
+ if (entries.length === 0) {
934
+ throw new IntelError(400, "import_invalid_bundle", "The bundle holds nothing to import");
935
+ }
936
+ if (entries.some((entry) => entry.kind === "flow") && actor.canCreateFlows !== true) {
937
+ throw new IntelError(403, "permission_required", "Importing a bundle that carries flows needs the flows/create permission");
938
+ }
939
+ const idMap = new Map();
940
+ for (const entry of entries) {
941
+ if (entry.oldId !== null)
942
+ idMap.set(entry.oldId, entry.newId);
943
+ }
944
+ const occurredAt = deps.now().toISOString();
945
+ const nodes = [];
946
+ const versions = [];
947
+ const flows = [];
948
+ const flowVersions = [];
949
+ // Text bodies and media types per new node, kept for the link pass below.
950
+ const documentBodies = new Map();
951
+ const pendingWrites = [];
952
+ function versionRowFor(entry, content, mediaType, contentHash, segment) {
953
+ const versionId = deps.id();
954
+ const contentKey = contentKeyFor(entry.newId, versionId);
955
+ pendingWrites.push({ key: contentKey, mediaType, body: content });
956
+ return {
957
+ id: versionId,
958
+ nodeId: entry.newId,
959
+ sequence: 1,
960
+ contentKey,
961
+ mediaType,
962
+ contentHash,
963
+ size: typeof content === "string"
964
+ ? new TextEncoder().encode(content).byteLength
965
+ : content.byteLength,
966
+ segment,
967
+ createdBy: actor.id,
968
+ createdAt: occurredAt,
969
+ };
970
+ }
971
+ for (const entry of entries) {
972
+ const parentId = entry.parentNewId ?? input.targetNodeId;
973
+ if (entry.kind === "flow") {
974
+ const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
975
+ let versionId = null;
976
+ if (text.trim().length > 0) {
977
+ let graph;
978
+ try {
979
+ graph = remapFlowGraph(FlowGraph.parse(JSON.parse(text)), idMap);
980
+ }
981
+ catch {
982
+ throw new IntelError(400, "import_invalid_flow", `Bundle entry is not a flow graph: ${entry.path}`);
983
+ }
984
+ versionId = deps.id();
985
+ flowVersions.push({
986
+ id: versionId,
987
+ flowId: entry.newId,
988
+ sequence: 1,
989
+ graph,
990
+ createdBy: actor.id,
991
+ createdAt: occurredAt,
992
+ });
993
+ }
994
+ flows.push({
995
+ /**
996
+ * ⚠️ **An import never carries a slash command over, and that is not a gap** (#775). A
997
+ * prompt name is unique across the whole installation the bundle lands IN, and the
998
+ * bundle knows nothing about it: importing a flow offered as `/billing` into a tree
999
+ * that already has one would fail the whole import over a name nobody chose here. The
1000
+ * import brings the content; offering it is a decision somebody makes afterwards, in
1001
+ * the installation that has to live with the name.
1002
+ */
1003
+ promptName: null,
1004
+ id: entry.newId,
1005
+ parentId,
1006
+ title: plainTitle(entry.title),
1007
+ description: entry.description,
1008
+ ownerId: actor.id,
1009
+ currentVersionId: versionId,
1010
+ // Publishing froze a decision in the OLD installation; the import hands over a draft
1011
+ // and whoever owns the new tree publishes it anew.
1012
+ publishedVersionId: null,
1013
+ createdAt: occurredAt,
1014
+ updatedAt: occurredAt,
1015
+ archivedAt: null,
1016
+ });
1017
+ continue;
1018
+ }
1019
+ let version = null;
1020
+ if (entry.kind === "document") {
1021
+ let text = entry.body === null ? "" : decodeText(entry.body, entry.path);
1022
+ const mediaType = entry.mediaType ?? "text/markdown";
1023
+ if (text.length > 0 && mediaType === BlockNoteMediaType) {
1024
+ text = remapDocumentLinks(text, idMap);
1025
+ }
1026
+ if (text.length > 0) {
1027
+ version = versionRowFor(entry, text, mediaType, await deps.hash(text), null);
1028
+ documentBodies.set(entry.newId, { mediaType, content: text });
1029
+ }
1030
+ }
1031
+ else if (entry.kind === "table") {
1032
+ const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
1033
+ if (text.trim().length > 0) {
1034
+ const header = parseCsv(text)[0] ?? [];
1035
+ if (header.length === 0 || header.every((column) => column.trim().length === 0)) {
1036
+ throw new IntelError(400, "import_invalid_table", `Bundle entry is not a table with a header row: ${entry.path}`);
1037
+ }
1038
+ // ⚠️ Distinct column names, read the same way `DefineTableInput` and
1039
+ // `RedefineTableInput` read them — case-insensitively (#322). Both refuse a duplicate,
1040
+ // and the import used to walk past both: a table could only enter the tree this way
1041
+ // with two columns of one name.
1042
+ //
1043
+ // The consumer assumes the opposite and says so. `packages/intel/ui/src/node-table` keys its
1044
+ // header cells and every row cell with the column name and carries the comment
1045
+ // "Column names are distinct by contract (`DefineTableInput`)" — true for the two write
1046
+ // paths, and false for this one. Two equal names are two React keys with one value, in
1047
+ // the header AND in every row.
1048
+ const seen = new Set(header.map((column) => column.trim().toLowerCase()));
1049
+ if (seen.size !== header.length) {
1050
+ throw new IntelError(400, "import_invalid_table", `Bundle entry has two columns of the same name: ${entry.path}`);
1051
+ }
1052
+ // The whole file as one snapshot: the same shape a definition or a redefine writes
1053
+ // (#135), so reading starts here and nothing older is expected to exist.
1054
+ version = versionRowFor(entry, text, TableMediaType, await deps.hash(text), "snapshot");
1055
+ }
1056
+ }
1057
+ else if (entry.kind === "board" || entry.kind === "agent") {
1058
+ /**
1059
+ * ⚠️ Refused by name, and the name says WHY rather than what the parser tripped over.
1060
+ *
1061
+ * Agents and Boards were parked with #385; nothing in this build can hold either kind.
1062
+ * A bundle that carries one is not malformed — it is a correct export of an installation
1063
+ * that still had the feature, and the person holding it is usually moving a tree between
1064
+ * two installations rather than the author of the file. Left to the schema they would get
1065
+ * `unexpected enum value`, look for a broken file, and find nothing wrong with it.
1066
+ *
1067
+ * The refusal names the entry, the kind and the branch the feature is parked on, so the
1068
+ * answer to "where did my board go" is in the message and not in somebody's memory.
1069
+ *
1070
+ * ⚠️ Only a MANIFEST can reach this, because only a manifest declares a kind. A naked
1071
+ * folder holding a `plan.board.json` imports it as an attachment — see `kindOfFile`.
1072
+ */
1073
+ throw new IntelError(400, "import_kind_parked", `Bundle entry “${entry.path}” is a ${entry.kind}, and ${entry.kind}s are not part of this installation any more (anchrd/intel#385). The bundle was exported from a version that still had them; the code is on the branch parked/agents-and-board. Remove the entry from the bundle to import the rest.`);
1074
+ }
1075
+ else if (entry.kind === "attachment" && entry.body !== null) {
1076
+ // ⚠️ `entry.body === null` reaches here only for a manifest entry that declared an
1077
+ // absence — a naked folder always has the bytes of the file it names. It used to be read
1078
+ // as `new Uint8Array(0)` and given a version of zero bytes, which is a DIFFERENT node
1079
+ // from the one that was exported: the export said "never uploaded to", and the import
1080
+ // would answer with an upload of nothing. No version is what came out of that
1081
+ // installation and no version is what goes back in (#560).
1082
+ version = versionRowFor(entry, entry.body, entry.mediaType ?? "application/octet-stream", await deps.hash(entry.body), null);
1083
+ }
1084
+ if (version !== null)
1085
+ versions.push(version);
1086
+ nodes.push({
1087
+ // Not carried over, for the reason the flow half above spells out: the catalogue belongs
1088
+ // to the installation, not to the bundle (#775).
1089
+ promptName: null,
1090
+ id: entry.newId,
1091
+ parentId,
1092
+ // Flows took the `continue` above; what reaches here is one of the six node kinds.
1093
+ kind: entry.kind,
1094
+ // A manifest title and a file name are both somebody else's text, and an export written
1095
+ // by an escaping chain carries the entity in both (#202).
1096
+ title: plainTitle(entry.title),
1097
+ description: entry.description,
1098
+ ownerId: actor.id,
1099
+ currentVersionId: version?.id ?? null,
1100
+ createdAt: occurredAt,
1101
+ updatedAt: occurredAt,
1102
+ archivedAt: null,
1103
+ });
1104
+ }
1105
+ // The link pass: what the imported texts now point at. Targets inside the import are linked
1106
+ // as written — they exist by the same transaction. Targets outside go through the same
1107
+ // visibility question a save asks, so an import cannot mint a link into a part of the tree
1108
+ // its author may not see.
1109
+ const links = [];
1110
+ const importedIds = new Set(nodes.map((node) => node.id));
1111
+ const outsideTargets = new Set();
1112
+ for (const [sourceId, body] of documentBodies) {
1113
+ for (const target of documentLinkTargets(body.mediaType, body.content)) {
1114
+ if (target === sourceId)
1115
+ continue;
1116
+ if (!importedIds.has(target))
1117
+ outsideTargets.add(target);
1118
+ }
1119
+ }
1120
+ const visibleOutside = new Set(outsideTargets.size === 0
1121
+ ? []
1122
+ : (await deps.repository.resolveVisibleTitles(actor, [...outsideTargets])).map((row) => row.nodeId));
1123
+ for (const [sourceId, body] of documentBodies) {
1124
+ for (const target of documentLinkTargets(body.mediaType, body.content)) {
1125
+ if (target === sourceId)
1126
+ continue;
1127
+ if (importedIds.has(target) || visibleOutside.has(target)) {
1128
+ links.push({ id: deps.id(), sourceNodeId: sourceId, targetNodeId: target });
1129
+ }
1130
+ }
1131
+ }
1132
+ const rootNodeIds = nodes
1133
+ .filter((node) => node.parentId === input.targetNodeId)
1134
+ .map((node) => node.id);
1135
+ const metadata = {
1136
+ manifestHash: await deps.hash(manifestText ?? [...contents.files.keys()].sort().join("\n")),
1137
+ nodes: nodes.length,
1138
+ flows: flows.length,
1139
+ rootNodeIds,
1140
+ };
1141
+ // R2 first, rows second, cleanup on refusal — the same discipline every single-version
1142
+ // write in `nodes.ts` follows, over many objects at once.
1143
+ const written = [];
1144
+ try {
1145
+ for (const write of pendingWrites) {
1146
+ if (typeof write.body === "string") {
1147
+ await deps.content.put(write.key, write.body, write.mediaType);
1148
+ }
1149
+ else {
1150
+ const copy = new Uint8Array(write.body.byteLength);
1151
+ copy.set(write.body);
1152
+ await deps.content.putBytes(write.key, copy.buffer, write.mediaType);
1153
+ }
1154
+ written.push(write.key);
1155
+ }
1156
+ const outcome = await deps.repository.importTree({
1157
+ nodes,
1158
+ versions,
1159
+ links,
1160
+ flows,
1161
+ flowVersions,
1162
+ actorId: actor.id,
1163
+ idempotencyKey: input.idempotencyKey,
1164
+ auditId: deps.id(),
1165
+ auditResourceId: input.targetNodeId ?? "root",
1166
+ metadata,
1167
+ occurredAt,
1168
+ });
1169
+ if (outcome === "replayed") {
1170
+ // A racing retry landed first; these objects belong to nobody's rows.
1171
+ await Promise.all(written.map(async (key) => await deps.content.delete(key).catch(() => undefined)));
1172
+ const stored = await deps.repository.findImportReplay(actor.id, input.idempotencyKey);
1173
+ return replayResult(stored ?? {});
1174
+ }
1175
+ }
1176
+ catch (error) {
1177
+ await Promise.all(written.map(async (key) => await deps.content.delete(key).catch(() => undefined)));
1178
+ throw error;
1179
+ }
1180
+ // Derived indexes only after the canonical write stands (#137): the queue reads D1 and R2,
1181
+ // and both now hold what it will find.
1182
+ for (const version of versions) {
1183
+ await deps.indexing.enqueue(version.id);
1184
+ }
1185
+ return {
1186
+ nodes: nodes.length,
1187
+ flows: flows.length,
1188
+ rootNodeIds,
1189
+ replayed: false,
1190
+ excluded: Excluded,
1191
+ };
1192
+ },
1193
+ };
1194
+ }