canopycms 0.0.68-int.98 → 0.1.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 (688) hide show
  1. package/CHANGELOG.md +2131 -0
  2. package/README.md +47 -63
  3. package/dist/ai/generate.d.ts +5 -0
  4. package/dist/ai/generate.js +244 -115
  5. package/dist/ai/index.d.ts +1 -1
  6. package/dist/ai/json-to-markdown.d.ts +21 -1
  7. package/dist/ai/json-to-markdown.js +120 -57
  8. package/dist/ai/references.d.ts +72 -0
  9. package/dist/ai/references.js +223 -0
  10. package/dist/ai/resolve-branch.js +9 -2
  11. package/dist/ai/to-plain-text.d.ts +17 -8
  12. package/dist/ai/to-plain-text.js +18 -15
  13. package/dist/ai/types.d.ts +84 -3
  14. package/dist/api/admin-branch-health.d.ts +24 -1
  15. package/dist/api/admin-branch-health.js +52 -20
  16. package/dist/api/admin.d.ts +23 -5
  17. package/dist/api/admin.js +36 -1
  18. package/dist/api/assets.js +88 -78
  19. package/dist/api/branch-create-window.d.ts +12 -0
  20. package/dist/api/branch-create-window.js +13 -0
  21. package/dist/api/branch-merge.d.ts +1 -1
  22. package/dist/api/branch-merge.js +1 -1
  23. package/dist/api/branch-review.d.ts +3 -3
  24. package/dist/api/branch-review.js +16 -7
  25. package/dist/api/branch-status.d.ts +16 -6
  26. package/dist/api/branch-status.js +57 -12
  27. package/dist/api/branch-withdraw.d.ts +2 -2
  28. package/dist/api/branch-withdraw.js +8 -3
  29. package/dist/api/branch.d.ts +25 -12
  30. package/dist/api/branch.js +164 -103
  31. package/dist/api/client.d.ts +78 -58
  32. package/dist/api/client.js +150 -117
  33. package/dist/api/comments.d.ts +12 -3
  34. package/dist/api/comments.js +118 -9
  35. package/dist/api/content.d.ts +9 -9
  36. package/dist/api/content.js +131 -22
  37. package/dist/api/entries-constants.d.ts +14 -3
  38. package/dist/api/entries-constants.js +14 -3
  39. package/dist/api/entries.d.ts +40 -2
  40. package/dist/api/entries.js +102 -6
  41. package/dist/api/github-sync.d.ts +19 -0
  42. package/dist/api/github-sync.js +50 -3
  43. package/dist/api/groups.d.ts +4 -4
  44. package/dist/api/groups.js +2 -1
  45. package/dist/api/guards.js +23 -3
  46. package/dist/api/index.d.ts +3 -1
  47. package/dist/api/index.js +1 -0
  48. package/dist/api/permissions.d.ts +21 -2
  49. package/dist/api/permissions.js +50 -3
  50. package/dist/api/request-deadline.d.ts +59 -0
  51. package/dist/api/request-deadline.js +73 -0
  52. package/dist/api/resolve-references.js +8 -7
  53. package/dist/api/route-builder.d.ts +14 -0
  54. package/dist/api/route-builder.js +14 -1
  55. package/dist/api/schema.d.ts +11 -9
  56. package/dist/api/schema.js +2 -0
  57. package/dist/api/settings-helpers.d.ts +5 -1
  58. package/dist/api/settings-helpers.js +10 -2
  59. package/dist/api/types.d.ts +18 -0
  60. package/dist/api/users-constants.d.ts +6 -0
  61. package/dist/api/users-constants.js +6 -0
  62. package/dist/assets/asset-url.d.ts +28 -16
  63. package/dist/assets/asset-url.js +51 -25
  64. package/dist/assets/finalize.js +3 -0
  65. package/dist/assets/index.d.ts +1 -1
  66. package/dist/assets/materialize.d.ts +153 -0
  67. package/dist/assets/materialize.js +402 -0
  68. package/dist/assets/store-local.d.ts +19 -5
  69. package/dist/assets/store-local.js +69 -10
  70. package/dist/assets/store-s3.d.ts +38 -7
  71. package/dist/assets/store-s3.js +128 -22
  72. package/dist/assets/transform-directives.d.ts +52 -13
  73. package/dist/assets/transform-directives.js +87 -27
  74. package/dist/assets/transform.d.ts +3 -3
  75. package/dist/assets/transform.js +13 -8
  76. package/dist/assets/types.d.ts +31 -4
  77. package/dist/auth/cache.d.ts +1 -1
  78. package/dist/auth/cache.js +1 -1
  79. package/dist/auth/caching-auth-plugin.d.ts +2 -0
  80. package/dist/auth/caching-auth-plugin.js +12 -2
  81. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  82. package/dist/auth/file-based-auth-cache.js +18 -8
  83. package/dist/auth/person-name.d.ts +5 -0
  84. package/dist/auth/person-name.js +8 -0
  85. package/dist/auth/plugin.d.ts +10 -1
  86. package/dist/auth/types.d.ts +2 -0
  87. package/dist/auth/user-metadata-lookup.d.ts +21 -0
  88. package/dist/auth/user-metadata-lookup.js +116 -0
  89. package/dist/authorization/branch.d.ts +1 -1
  90. package/dist/authorization/branch.js +12 -13
  91. package/dist/authorization/groups/schema.d.ts +4 -4
  92. package/dist/authorization/helpers.d.ts +8 -0
  93. package/dist/authorization/helpers.js +8 -0
  94. package/dist/authorization/index.d.ts +1 -1
  95. package/dist/authorization/index.js +1 -1
  96. package/dist/authorization/protected-branch.d.ts +5 -4
  97. package/dist/authorization/protected-branch.js +7 -5
  98. package/dist/branch-health.d.ts +35 -8
  99. package/dist/branch-health.js +54 -18
  100. package/dist/branch-metadata-error.d.ts +27 -0
  101. package/dist/branch-metadata-error.js +23 -0
  102. package/dist/branch-metadata-file.d.ts +205 -24
  103. package/dist/branch-metadata-file.js +126 -25
  104. package/dist/branch-metadata.d.ts +45 -6
  105. package/dist/branch-metadata.js +142 -50
  106. package/dist/branch-provisioning.d.ts +167 -0
  107. package/dist/branch-provisioning.js +501 -0
  108. package/dist/branch-registry.d.ts +8 -0
  109. package/dist/branch-registry.js +61 -9
  110. package/dist/branch-schema-cache.d.ts +29 -12
  111. package/dist/branch-schema-cache.js +64 -21
  112. package/dist/branch-sparse.d.ts +23 -0
  113. package/dist/branch-sparse.js +118 -0
  114. package/dist/branch-workspace.d.ts +42 -3
  115. package/dist/branch-workspace.js +239 -100
  116. package/dist/build/asset-refs.d.ts +94 -0
  117. package/dist/build/asset-refs.js +267 -0
  118. package/dist/build/generate-ai-content.js +5 -0
  119. package/dist/build/inline-flight.d.ts +37 -0
  120. package/dist/build/inline-flight.js +142 -0
  121. package/dist/cli/asset-refs.d.ts +52 -0
  122. package/dist/cli/asset-refs.js +194 -0
  123. package/dist/cli/cli.d.ts +15 -1
  124. package/dist/cli/cli.js +8543 -3193
  125. package/dist/cli/configured-asset-store.d.ts +12 -0
  126. package/dist/cli/configured-asset-store.js +46 -0
  127. package/dist/cli/generate-ai-content.js +4244 -1072
  128. package/dist/cli/init.js +217 -126
  129. package/dist/cli/project-detect.d.ts +2 -0
  130. package/dist/cli/project-detect.js +2 -0
  131. package/dist/cli/template-files/Dockerfile.cms.template +13 -5
  132. package/dist/cli/template-files/cms-stack.ts.template +16 -13
  133. package/dist/client.d.ts +1 -2
  134. package/dist/client.js +1 -2
  135. package/dist/comment-store.d.ts +25 -0
  136. package/dist/comment-store.js +43 -8
  137. package/dist/config/flatten.js +2 -0
  138. package/dist/config/helpers.js +2 -2
  139. package/dist/config/index.d.ts +1 -1
  140. package/dist/config/schemas/config.d.ts +12 -29
  141. package/dist/config/schemas/config.js +9 -12
  142. package/dist/config/schemas/field.d.ts +3 -0
  143. package/dist/config/schemas/field.js +58 -4
  144. package/dist/config/schemas/media.d.ts +4 -13
  145. package/dist/config/schemas/media.js +5 -8
  146. package/dist/config/schemas/url.d.ts +2 -20
  147. package/dist/config/schemas/url.js +2 -25
  148. package/dist/config/types.d.ts +116 -22
  149. package/dist/config/types.js +3 -9
  150. package/dist/config/validation.d.ts +25 -4
  151. package/dist/config/validation.js +116 -3
  152. package/dist/content-id-index.js +2 -2
  153. package/dist/content-index-generation.d.ts +1 -1
  154. package/dist/content-index-generation.js +1 -1
  155. package/dist/content-listing.d.ts +10 -1
  156. package/dist/content-listing.js +22 -5
  157. package/dist/content-reader.d.ts +9 -0
  158. package/dist/content-reader.js +16 -11
  159. package/dist/content-store.d.ts +75 -12
  160. package/dist/content-store.js +249 -86
  161. package/dist/content-tree.js +2 -2
  162. package/dist/context.js +5 -2
  163. package/dist/dangling-reference-log.d.ts +17 -0
  164. package/dist/dangling-reference-log.js +29 -0
  165. package/dist/dev-content-watcher.d.ts +1 -1
  166. package/dist/dev-content-watcher.js +1 -1
  167. package/dist/editor/BranchManager.d.ts +23 -7
  168. package/dist/editor/BranchManager.js +119 -54
  169. package/dist/editor/CanopyEditor.d.ts +1 -1
  170. package/dist/editor/CanopyEditor.js +1 -2
  171. package/dist/editor/CommentsPanel.d.ts +14 -0
  172. package/dist/editor/CommentsPanel.js +68 -43
  173. package/dist/editor/Editor.d.ts +11 -5
  174. package/dist/editor/Editor.js +891 -218
  175. package/dist/editor/EditorAuthGate.d.ts +7 -1
  176. package/dist/editor/EditorAuthGate.js +41 -9
  177. package/dist/editor/EntryNavigator.d.ts +22 -0
  178. package/dist/editor/EntryNavigator.js +353 -105
  179. package/dist/editor/FormHeader.d.ts +25 -0
  180. package/dist/editor/FormHeader.js +82 -0
  181. package/dist/editor/FormRenderer.d.ts +27 -3
  182. package/dist/editor/FormRenderer.js +171 -76
  183. package/dist/editor/PreviewFrame.d.ts +40 -0
  184. package/dist/editor/PreviewFrame.js +215 -0
  185. package/dist/editor/PreviewPane.d.ts +47 -0
  186. package/dist/editor/PreviewPane.js +147 -0
  187. package/dist/editor/PreviewToolbarTools.d.ts +22 -0
  188. package/dist/editor/PreviewToolbarTools.js +73 -0
  189. package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
  190. package/dist/editor/admin/SystemHealthPanel.js +163 -33
  191. package/dist/editor/admin/useSystemHealth.d.ts +18 -0
  192. package/dist/editor/admin/useSystemHealth.js +70 -1
  193. package/dist/editor/branch-status.d.ts +17 -0
  194. package/dist/editor/branch-status.js +29 -0
  195. package/dist/editor/canopy-path.d.ts +9 -6
  196. package/dist/editor/canopy-path.js +8 -11
  197. package/dist/editor/client-reference-resolver.d.ts +43 -16
  198. package/dist/editor/client-reference-resolver.js +155 -64
  199. package/dist/editor/comments/BranchComments.d.ts +1 -1
  200. package/dist/editor/comments/BranchComments.js +2 -2
  201. package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
  202. package/dist/editor/comments/CloseRefusedNotice.js +7 -0
  203. package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
  204. package/dist/editor/comments/CommentThreadsPopover.js +274 -0
  205. package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
  206. package/dist/editor/comments/CommentWriteAlert.js +13 -0
  207. package/dist/editor/comments/EntryComments.d.ts +10 -4
  208. package/dist/editor/comments/EntryComments.js +14 -17
  209. package/dist/editor/comments/FieldWrapper.d.ts +18 -4
  210. package/dist/editor/comments/FieldWrapper.js +62 -28
  211. package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
  212. package/dist/editor/comments/InlineCommentThread.js +11 -47
  213. package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
  214. package/dist/editor/comments/ThreadCarousel.js +18 -21
  215. package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
  216. package/dist/editor/comments/ThreadReplyControls.js +92 -0
  217. package/dist/editor/comments/field-comment-context.d.ts +14 -0
  218. package/dist/editor/comments/field-comment-context.js +14 -0
  219. package/dist/editor/comments/form-comments.d.ts +48 -0
  220. package/dist/editor/comments/form-comments.js +178 -0
  221. package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
  222. package/dist/editor/comments/item-anchor-copy.js +6 -0
  223. package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
  224. package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
  225. package/dist/editor/comments/popover-placement.d.ts +18 -0
  226. package/dist/editor/comments/popover-placement.js +58 -0
  227. package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
  228. package/dist/editor/comments/use-tracked-writes.js +53 -0
  229. package/dist/editor/components/BranchAccessList.d.ts +15 -0
  230. package/dist/editor/components/BranchAccessList.js +52 -0
  231. package/dist/editor/components/BranchLockBanner.d.ts +22 -0
  232. package/dist/editor/components/BranchLockBanner.js +39 -0
  233. package/dist/editor/components/BranchPill.d.ts +32 -0
  234. package/dist/editor/components/BranchPill.js +33 -0
  235. package/dist/editor/components/BranchesDrawer.d.ts +11 -0
  236. package/dist/editor/components/BranchesDrawer.js +5 -0
  237. package/dist/editor/components/ConfirmDeleteModal.d.ts +6 -1
  238. package/dist/editor/components/ConfirmDeleteModal.js +3 -2
  239. package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
  240. package/dist/editor/components/DestructiveMenuItem.js +8 -0
  241. package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
  242. package/dist/editor/components/EditorCrashScreen.js +22 -0
  243. package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
  244. package/dist/editor/components/EditorErrorBoundary.js +40 -0
  245. package/dist/editor/components/EditorHeader.d.ts +56 -106
  246. package/dist/editor/components/EditorHeader.js +130 -131
  247. package/dist/editor/components/EditorRail.d.ts +49 -0
  248. package/dist/editor/components/EditorRail.js +65 -0
  249. package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
  250. package/dist/editor/components/EntryBreadcrumb.js +56 -0
  251. package/dist/editor/components/EntryCreateModal.d.ts +12 -7
  252. package/dist/editor/components/EntryCreateModal.js +51 -57
  253. package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
  254. package/dist/editor/components/NoEditPermissionNotice.js +5 -4
  255. package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
  256. package/dist/editor/components/QuickOpenPalette.js +66 -0
  257. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  258. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  259. package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
  260. package/dist/editor/components/ReasonedMenuItem.js +15 -0
  261. package/dist/editor/components/ReferencedByList.d.ts +11 -0
  262. package/dist/editor/components/ReferencedByList.js +28 -0
  263. package/dist/editor/components/RelativeTime.d.ts +10 -0
  264. package/dist/editor/components/RelativeTime.js +10 -0
  265. package/dist/editor/components/RenameEntryModal.d.ts +9 -1
  266. package/dist/editor/components/RenameEntryModal.js +19 -31
  267. package/dist/editor/components/RetryToastMessage.d.ts +9 -0
  268. package/dist/editor/components/RetryToastMessage.js +6 -0
  269. package/dist/editor/components/SaveAllResult.d.ts +25 -0
  270. package/dist/editor/components/SaveAllResult.js +86 -0
  271. package/dist/editor/components/SaveSplitButton.d.ts +30 -0
  272. package/dist/editor/components/SaveSplitButton.js +108 -0
  273. package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
  274. package/dist/editor/components/StagedChangesDrawer.js +92 -0
  275. package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
  276. package/dist/editor/components/UnavailableEntryNotice.js +9 -0
  277. package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
  278. package/dist/editor/components/UnavailableTypeMessage.js +9 -0
  279. package/dist/editor/components/UndoToastMessage.d.ts +18 -0
  280. package/dist/editor/components/UndoToastMessage.js +10 -0
  281. package/dist/editor/components/UserBadge.d.ts +2 -5
  282. package/dist/editor/components/UserBadge.js +62 -33
  283. package/dist/editor/components/ValidationSummary.d.ts +14 -0
  284. package/dist/editor/components/ValidationSummary.js +24 -0
  285. package/dist/editor/components/index.d.ts +2 -2
  286. package/dist/editor/components/index.js +2 -2
  287. package/dist/editor/context/ApiClientContext.d.ts +8 -2
  288. package/dist/editor/context/ApiClientContext.js +19 -8
  289. package/dist/editor/context/AssetContext.d.ts +13 -35
  290. package/dist/editor/context/AssetContext.js +12 -31
  291. package/dist/editor/context/EditorStateContext.d.ts +4 -4
  292. package/dist/editor/context/EditorStateContext.js +4 -4
  293. package/dist/editor/context/SWRProvider.d.ts +3 -3
  294. package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
  295. package/dist/editor/context/SiteMdxAllowContext.js +7 -0
  296. package/dist/editor/context/index.d.ts +2 -1
  297. package/dist/editor/context/index.js +2 -1
  298. package/dist/editor/contrast.d.ts +19 -0
  299. package/dist/editor/contrast.js +41 -0
  300. package/dist/editor/copy.d.ts +18 -0
  301. package/dist/editor/copy.js +18 -0
  302. package/dist/editor/editor-config.js +16 -0
  303. package/dist/editor/editor-utils.d.ts +34 -8
  304. package/dist/editor/editor-utils.js +42 -20
  305. package/dist/editor/entry-search.d.ts +20 -0
  306. package/dist/editor/entry-search.js +34 -0
  307. package/dist/editor/field-breadcrumb.d.ts +12 -0
  308. package/dist/editor/field-breadcrumb.js +85 -0
  309. package/dist/editor/field-props.d.ts +86 -0
  310. package/dist/editor/field-props.js +27 -0
  311. package/dist/editor/field-target.d.ts +30 -0
  312. package/dist/editor/field-target.js +77 -0
  313. package/dist/editor/fields/BlockField.d.ts +12 -0
  314. package/dist/editor/fields/BlockField.js +89 -73
  315. package/dist/editor/fields/CodeField.d.ts +2 -0
  316. package/dist/editor/fields/CodeField.js +6 -2
  317. package/dist/editor/fields/DateTimeField.d.ts +4 -29
  318. package/dist/editor/fields/DateTimeField.js +79 -41
  319. package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
  320. package/dist/editor/fields/ExpandAllButton.js +4 -0
  321. package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
  322. package/dist/editor/fields/FieldCrashFallback.js +30 -0
  323. package/dist/editor/fields/FieldLabel.d.ts +37 -0
  324. package/dist/editor/fields/FieldLabel.js +19 -0
  325. package/dist/editor/fields/ImageField.d.ts +4 -0
  326. package/dist/editor/fields/ImageField.js +29 -14
  327. package/dist/editor/fields/InlineGroupField.js +4 -3
  328. package/dist/editor/fields/ItemList.d.ts +42 -0
  329. package/dist/editor/fields/ItemList.js +130 -0
  330. package/dist/editor/fields/MarkdownField.d.ts +32 -0
  331. package/dist/editor/fields/MarkdownField.js +223 -46
  332. package/dist/editor/fields/MdxImageDialog.js +1 -1
  333. package/dist/editor/fields/NumberField.d.ts +2 -0
  334. package/dist/editor/fields/NumberField.js +8 -5
  335. package/dist/editor/fields/NumberListField.d.ts +2 -0
  336. package/dist/editor/fields/NumberListField.js +19 -16
  337. package/dist/editor/fields/ObjectField.d.ts +2 -7
  338. package/dist/editor/fields/ObjectField.js +15 -5
  339. package/dist/editor/fields/ObjectListField.d.ts +29 -0
  340. package/dist/editor/fields/ObjectListField.js +57 -0
  341. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  342. package/dist/editor/fields/ReferenceField.js +34 -9
  343. package/dist/editor/fields/SelectField.d.ts +2 -0
  344. package/dist/editor/fields/SelectField.js +11 -2
  345. package/dist/editor/fields/StringListField.d.ts +4 -1
  346. package/dist/editor/fields/StringListField.js +22 -8
  347. package/dist/editor/fields/TextField.d.ts +2 -0
  348. package/dist/editor/fields/TextField.js +6 -2
  349. package/dist/editor/fields/ToggleField.d.ts +2 -0
  350. package/dist/editor/fields/ToggleField.js +8 -3
  351. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  352. package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
  353. package/dist/editor/fields/form-expansion.d.ts +9 -0
  354. package/dist/editor/fields/form-expansion.js +2 -0
  355. package/dist/editor/fields/item-expansion.d.ts +15 -0
  356. package/dist/editor/fields/item-expansion.js +60 -0
  357. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  358. package/dist/editor/fields/list-item-keys.js +50 -0
  359. package/dist/editor/fields/list-item-title.d.ts +20 -0
  360. package/dist/editor/fields/list-item-title.js +85 -0
  361. package/dist/editor/fields/markdown-export-options.d.ts +2 -0
  362. package/dist/editor/fields/markdown-export-options.js +8 -0
  363. package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
  364. package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
  365. package/dist/editor/fields/mdx-jsx-support.d.ts +5 -1
  366. package/dist/editor/fields/mdx-jsx-support.js +157 -69
  367. package/dist/editor/fields/rich-text-failures.d.ts +5 -0
  368. package/dist/editor/fields/rich-text-failures.js +26 -0
  369. package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
  370. package/dist/editor/fields/toolbar-wrap.js +35 -0
  371. package/dist/editor/form-outline.d.ts +13 -0
  372. package/dist/editor/form-outline.js +70 -0
  373. package/dist/editor/group-manager/GroupCard.d.ts +4 -1
  374. package/dist/editor/group-manager/GroupCard.js +12 -3
  375. package/dist/editor/group-manager/GroupForm.js +1 -1
  376. package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
  377. package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
  378. package/dist/editor/group-manager/MemberList.d.ts +2 -1
  379. package/dist/editor/group-manager/MemberList.js +15 -4
  380. package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
  381. package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
  382. package/dist/editor/group-manager/index.js +34 -5
  383. package/dist/editor/group-manager/types.d.ts +5 -2
  384. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  385. package/dist/editor/hooks/create-branch-request.js +54 -0
  386. package/dist/editor/hooks/save-all.d.ts +68 -0
  387. package/dist/editor/hooks/save-all.js +61 -0
  388. package/dist/editor/hooks/useBranchActions.d.ts +11 -1
  389. package/dist/editor/hooks/useBranchActions.js +42 -19
  390. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  391. package/dist/editor/hooks/useBranchManager.js +321 -125
  392. package/dist/editor/hooks/useBranchesData.d.ts +8 -4
  393. package/dist/editor/hooks/useBranchesData.js +9 -5
  394. package/dist/editor/hooks/useCommentSystem.d.ts +14 -2
  395. package/dist/editor/hooks/useCommentSystem.js +79 -55
  396. package/dist/editor/hooks/useCommentsData.d.ts +11 -7
  397. package/dist/editor/hooks/useCommentsData.js +20 -19
  398. package/dist/editor/hooks/useDraftManager.d.ts +77 -3
  399. package/dist/editor/hooks/useDraftManager.js +887 -94
  400. package/dist/editor/hooks/useEditorLayout.d.ts +53 -7
  401. package/dist/editor/hooks/useEditorLayout.js +98 -20
  402. package/dist/editor/hooks/useEntriesData.d.ts +2 -1
  403. package/dist/editor/hooks/useEntriesData.js +18 -4
  404. package/dist/editor/hooks/useEntryManager.d.ts +41 -2
  405. package/dist/editor/hooks/useEntryManager.js +227 -41
  406. package/dist/editor/hooks/useGroupManager.js +13 -16
  407. package/dist/editor/hooks/usePermissionManager.js +14 -6
  408. package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
  409. package/dist/editor/hooks/usePreviewMarks.js +33 -0
  410. package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
  411. package/dist/editor/hooks/useReferenceResolution.js +34 -177
  412. package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
  413. package/dist/editor/hooks/useSchemaManager.js +97 -39
  414. package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
  415. package/dist/editor/hooks/useUserMetadata.js +76 -47
  416. package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
  417. package/dist/editor/hooks/user-metadata-batcher.js +33 -0
  418. package/dist/editor/media/AssetCard.d.ts +6 -7
  419. package/dist/editor/media/AssetCard.js +24 -31
  420. package/dist/editor/media/AssetListRow.d.ts +16 -0
  421. package/dist/editor/media/AssetListRow.js +41 -0
  422. package/dist/editor/media/CropStep.js +6 -1
  423. package/dist/editor/media/MediaLibrary.d.ts +4 -3
  424. package/dist/editor/media/MediaLibrary.js +11 -4
  425. package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
  426. package/dist/editor/media/MediaLibraryBody.js +195 -80
  427. package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
  428. package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
  429. package/dist/editor/media/crop-math.d.ts +3 -4
  430. package/dist/editor/media/crop-math.js +11 -22
  431. package/dist/editor/media/editor-image-src.d.ts +8 -0
  432. package/dist/editor/media/editor-image-src.js +10 -0
  433. package/dist/editor/media/format-bytes.d.ts +2 -0
  434. package/dist/editor/media/format-bytes.js +23 -0
  435. package/dist/editor/media/media-library-prefs.d.ts +23 -0
  436. package/dist/editor/media/media-library-prefs.js +49 -0
  437. package/dist/editor/media/media-library-view.d.ts +34 -0
  438. package/dist/editor/media/media-library-view.js +40 -0
  439. package/dist/editor/media/useAssetLibrary.d.ts +30 -0
  440. package/dist/editor/media/useAssetLibrary.js +126 -0
  441. package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
  442. package/dist/editor/media/useStableAssetOrder.js +37 -0
  443. package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
  444. package/dist/editor/media/useThumbnailFailure.js +16 -0
  445. package/dist/editor/permission-manager/GroupSelector.js +2 -2
  446. package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
  447. package/dist/editor/permission-manager/PermissionEditor.js +15 -4
  448. package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
  449. package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
  450. package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
  451. package/dist/editor/permission-manager/UserSelector.js +3 -3
  452. package/dist/editor/permission-manager/index.js +12 -3
  453. package/dist/editor/permission-manager/types.d.ts +5 -2
  454. package/dist/editor/platform.d.ts +3 -0
  455. package/dist/editor/platform.js +9 -0
  456. package/dist/editor/preview-asset-base.d.ts +9 -0
  457. package/dist/editor/preview-asset-base.js +17 -0
  458. package/dist/editor/preview-bridge.d.ts +51 -27
  459. package/dist/editor/preview-bridge.js +103 -144
  460. package/dist/editor/preview-marks.d.ts +15 -0
  461. package/dist/editor/preview-marks.js +74 -0
  462. package/dist/editor/preview-path.d.ts +12 -0
  463. package/dist/editor/preview-path.js +45 -1
  464. package/dist/editor/preview-scale.d.ts +27 -0
  465. package/dist/editor/preview-scale.js +34 -0
  466. package/dist/editor/raw-asset-base.d.ts +5 -0
  467. package/dist/editor/raw-asset-base.js +14 -0
  468. package/dist/editor/relative-time.d.ts +4 -4
  469. package/dist/editor/relative-time.js +16 -8
  470. package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
  471. package/dist/editor/schema-editor/CollectionEditor.js +361 -62
  472. package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
  473. package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
  474. package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
  475. package/dist/editor/schema-editor/collection-draft.js +40 -0
  476. package/dist/editor/schema-editor/index.d.ts +1 -1
  477. package/dist/editor/schema-label.d.ts +10 -0
  478. package/dist/editor/schema-label.js +17 -0
  479. package/dist/editor/settings-conflict.d.ts +5 -0
  480. package/dist/editor/settings-conflict.js +8 -0
  481. package/dist/editor/shell/ActivityRail.d.ts +37 -0
  482. package/dist/editor/shell/ActivityRail.js +91 -0
  483. package/dist/editor/shell/EditorShell.d.ts +17 -0
  484. package/dist/editor/shell/EditorShell.js +31 -0
  485. package/dist/editor/shell/PaneLayout.d.ts +28 -0
  486. package/dist/editor/shell/PaneLayout.js +116 -0
  487. package/dist/editor/shell/SidePanelHost.d.ts +35 -0
  488. package/dist/editor/shell/SidePanelHost.js +184 -0
  489. package/dist/editor/shell/TopBar.d.ts +12 -0
  490. package/dist/editor/shell/TopBar.js +14 -0
  491. package/dist/editor/shell/host-chrome.d.ts +16 -0
  492. package/dist/editor/shell/host-chrome.js +69 -0
  493. package/dist/editor/slug.d.ts +16 -0
  494. package/dist/editor/slug.js +43 -0
  495. package/dist/editor/theme.d.ts +2 -1
  496. package/dist/editor/theme.js +314 -13
  497. package/dist/editor/unanswered.d.ts +17 -0
  498. package/dist/editor/unanswered.js +26 -0
  499. package/dist/editor/unavailable-entry-type.d.ts +20 -0
  500. package/dist/editor/unavailable-entry-type.js +28 -0
  501. package/dist/editor/undo-removal.d.ts +57 -0
  502. package/dist/editor/undo-removal.js +163 -0
  503. package/dist/editor/user-display.d.ts +4 -0
  504. package/dist/editor/user-display.js +5 -0
  505. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  506. package/dist/editor/utils/confirm-modal.js +12 -0
  507. package/dist/editor/utils/editor-errors.d.ts +19 -0
  508. package/dist/editor/utils/editor-errors.js +32 -0
  509. package/dist/editor/utils/focus-return.d.ts +56 -0
  510. package/dist/editor/utils/focus-return.js +106 -0
  511. package/dist/entry-link-resolver.d.ts +11 -1
  512. package/dist/entry-link-resolver.js +65 -5
  513. package/dist/entry-schema-registry.d.ts +3 -3
  514. package/dist/entry-schema-registry.js +7 -4
  515. package/dist/entry-schema.d.ts +49 -3
  516. package/dist/entry-schema.js +18 -0
  517. package/dist/git-manager.d.ts +172 -10
  518. package/dist/git-manager.js +449 -107
  519. package/dist/github-service.d.ts +39 -2
  520. package/dist/github-service.js +101 -7
  521. package/dist/http/handler.js +74 -9
  522. package/dist/http/types.d.ts +2 -0
  523. package/dist/http/worker-not-ready.d.ts +9 -4
  524. package/dist/http/worker-not-ready.js +59 -6
  525. package/dist/index.d.ts +2 -0
  526. package/dist/index.js +3 -0
  527. package/dist/item-keys.d.ts +46 -0
  528. package/dist/item-keys.js +235 -0
  529. package/dist/operating-mode/editor-mode-check.d.ts +15 -0
  530. package/dist/operating-mode/editor-mode-check.js +24 -0
  531. package/dist/operating-mode/mode-env.d.ts +13 -14
  532. package/dist/operating-mode/mode-env.js +13 -14
  533. package/dist/operating-mode/types.d.ts +2 -2
  534. package/dist/paths/branch.d.ts +0 -1
  535. package/dist/paths/branch.js +1 -8
  536. package/dist/paths/index.d.ts +1 -1
  537. package/dist/paths/index.js +1 -1
  538. package/dist/paths/validation.js +0 -2
  539. package/dist/preview.d.ts +8 -0
  540. package/dist/preview.js +7 -0
  541. package/dist/reference-resolver.js +1 -0
  542. package/dist/resolve-canopy-user.d.ts +1 -1
  543. package/dist/resolve-canopy-user.js +1 -1
  544. package/dist/schema/meta-loader.d.ts +18 -8
  545. package/dist/schema/meta-loader.js +42 -9
  546. package/dist/schema/registry-fingerprint.d.ts +14 -0
  547. package/dist/schema/registry-fingerprint.js +63 -0
  548. package/dist/schema/resolver.d.ts +7 -4
  549. package/dist/schema/resolver.js +7 -6
  550. package/dist/schema/schema-store.d.ts +7 -10
  551. package/dist/schema/schema-store.js +13 -11
  552. package/dist/schema/schema-unavailable-error.d.ts +11 -0
  553. package/dist/schema/schema-unavailable-error.js +15 -0
  554. package/dist/schema/types.d.ts +22 -0
  555. package/dist/schema-registry-record.d.ts +35 -0
  556. package/dist/schema-registry-record.js +134 -0
  557. package/dist/server.d.ts +79 -9
  558. package/dist/server.js +62 -8
  559. package/dist/services.d.ts +33 -5
  560. package/dist/services.js +155 -37
  561. package/dist/settings-workspace.d.ts +23 -3
  562. package/dist/settings-workspace.js +159 -45
  563. package/dist/stamp-on-publish.d.ts +36 -0
  564. package/dist/stamp-on-publish.js +71 -0
  565. package/dist/static/index.d.ts +58 -5
  566. package/dist/static/index.js +133 -3
  567. package/dist/static/seo.d.ts +5 -5
  568. package/dist/static/seo.js +5 -5
  569. package/dist/submission-attribution.d.ts +8 -0
  570. package/dist/submission-attribution.js +22 -0
  571. package/dist/system-users.d.ts +12 -0
  572. package/dist/system-users.js +12 -0
  573. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  574. package/dist/task-queue/cms-task-queue.js +1 -1
  575. package/dist/task-queue/index.d.ts +1 -1
  576. package/dist/task-queue/index.js +1 -1
  577. package/dist/task-queue/task-queue.d.ts +6 -0
  578. package/dist/task-queue/task-queue.js +29 -0
  579. package/dist/task-queue/worker-status.d.ts +35 -9
  580. package/dist/task-queue/worker-status.js +84 -17
  581. package/dist/types.d.ts +90 -9
  582. package/dist/url-collision.js +3 -3
  583. package/dist/url-exclusivity-fixtures.d.ts +8 -1
  584. package/dist/url-exclusivity-fixtures.js +11 -1
  585. package/dist/user.js +2 -1
  586. package/dist/utils/atomic-write.js +2 -1
  587. package/dist/utils/base-branch.d.ts +13 -0
  588. package/dist/utils/base-branch.js +23 -0
  589. package/dist/utils/content-serialize.d.ts +15 -7
  590. package/dist/utils/content-serialize.js +353 -51
  591. package/dist/utils/content-write-lock.d.ts +6 -1
  592. package/dist/utils/content-write-lock.js +21 -6
  593. package/dist/utils/debug.d.ts +1 -1
  594. package/dist/utils/debug.js +5 -3
  595. package/dist/utils/error.js +13 -2
  596. package/dist/utils/escape-text.d.ts +7 -0
  597. package/dist/utils/escape-text.js +27 -0
  598. package/dist/utils/fs.d.ts +12 -4
  599. package/dist/utils/fs.js +18 -5
  600. package/dist/utils/git.d.ts +21 -1
  601. package/dist/utils/git.js +63 -8
  602. package/dist/utils/json-source-splice.d.ts +14 -0
  603. package/dist/utils/json-source-splice.js +120 -0
  604. package/dist/utils/markdown-body-splice.d.ts +39 -0
  605. package/dist/utils/markdown-body-splice.js +421 -0
  606. package/dist/utils/occ-json-write.js +18 -12
  607. package/dist/utils/provision-log.d.ts +25 -0
  608. package/dist/utils/provision-log.js +49 -0
  609. package/dist/utils/provisioning-lock.d.ts +32 -1
  610. package/dist/utils/provisioning-lock.js +78 -32
  611. package/dist/utils/sanitize-href.d.ts +9 -23
  612. package/dist/utils/sanitize-href.js +14 -28
  613. package/dist/utils/title-field.d.ts +10 -0
  614. package/dist/utils/title-field.js +47 -0
  615. package/dist/utils/yaml-source-splice.d.ts +58 -0
  616. package/dist/utils/yaml-source-splice.js +515 -0
  617. package/dist/validation/block-structural-keys.d.ts +1 -1
  618. package/dist/validation/block-structural-keys.js +1 -1
  619. package/dist/validation/deletion-checker.d.ts +21 -29
  620. package/dist/validation/deletion-checker.js +41 -117
  621. package/dist/validation/entry-validator.d.ts +6 -10
  622. package/dist/validation/entry-validator.js +19 -26
  623. package/dist/validation/field-traversal.d.ts +22 -0
  624. package/dist/validation/field-traversal.js +39 -8
  625. package/dist/validation/markdown-safety.d.ts +68 -0
  626. package/dist/validation/markdown-safety.js +596 -0
  627. package/dist/validation/mdx-allowlist.d.ts +55 -0
  628. package/dist/validation/mdx-allowlist.js +183 -0
  629. package/dist/validation/reference-validator.d.ts +2 -0
  630. package/dist/validation/reference-validator.js +3 -0
  631. package/dist/version.d.ts +1 -1
  632. package/dist/version.js +1 -1
  633. package/dist/worker/branch-policy.d.ts +124 -0
  634. package/dist/worker/branch-policy.js +243 -0
  635. package/dist/worker/bundle-header.d.ts +117 -0
  636. package/dist/worker/bundle-header.js +253 -0
  637. package/dist/worker/cms-worker.d.ts +236 -142
  638. package/dist/worker/cms-worker.js +772 -285
  639. package/dist/worker/gateway-egress.d.ts +43 -0
  640. package/dist/worker/gateway-egress.js +209 -0
  641. package/dist/worker/gateway-instance.d.ts +42 -0
  642. package/dist/worker/gateway-instance.js +129 -0
  643. package/dist/worker/git-process.d.ts +81 -0
  644. package/dist/worker/git-process.js +234 -0
  645. package/dist/worker/git-sync.d.ts +34 -17
  646. package/dist/worker/git-sync.js +394 -131
  647. package/dist/worker/github-auth.d.ts +9 -9
  648. package/dist/worker/github-auth.js +13 -15
  649. package/dist/worker/github-gateway-http.d.ts +62 -0
  650. package/dist/worker/github-gateway-http.js +977 -0
  651. package/dist/worker/github-gateway-wire.d.ts +590 -0
  652. package/dist/worker/github-gateway-wire.js +488 -0
  653. package/dist/worker/github-gateway.d.ts +238 -0
  654. package/dist/worker/github-gateway.js +726 -0
  655. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  656. package/dist/worker/github-mirror-upkeep.js +266 -0
  657. package/dist/worker/github-mirror.d.ts +364 -0
  658. package/dist/worker/github-mirror.js +1030 -0
  659. package/dist/worker/github-transfer.d.ts +56 -0
  660. package/dist/worker/github-transfer.js +444 -0
  661. package/dist/worker/history-rewrite.d.ts +4 -5
  662. package/dist/worker/history-rewrite.js +12 -17
  663. package/dist/worker/provisioned-workspace.d.ts +5 -5
  664. package/dist/worker/provisioned-workspace.js +5 -5
  665. package/dist/worker/rebase.d.ts +1 -1
  666. package/dist/worker/rebase.js +58 -54
  667. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  668. package/dist/worker/remote-git-maintenance.js +12 -0
  669. package/dist/worker/schema-gate.d.ts +49 -0
  670. package/dist/worker/schema-gate.js +226 -0
  671. package/dist/worker/self-checks.d.ts +107 -0
  672. package/dist/worker/self-checks.js +358 -0
  673. package/dist/worker/shared-repo-git.d.ts +96 -0
  674. package/dist/worker/shared-repo-git.js +459 -0
  675. package/dist/worker/sparse-cone.d.ts +20 -0
  676. package/dist/worker/sparse-cone.js +136 -0
  677. package/dist/worker/stale-mirror.d.ts +82 -0
  678. package/dist/worker/stale-mirror.js +130 -0
  679. package/dist/worker/task-runner.d.ts +5 -3
  680. package/dist/worker/task-runner.js +287 -186
  681. package/dist/worker/worker-context.d.ts +36 -37
  682. package/package.json +35 -7
  683. package/dist/editor/EditorPanes.d.ts +0 -10
  684. package/dist/editor/EditorPanes.js +0 -98
  685. package/dist/editor/components/EditorFooter.d.ts +0 -11
  686. package/dist/editor/components/EditorFooter.js +0 -9
  687. package/dist/editor/components/EditorSidebar.d.ts +0 -67
  688. package/dist/editor/components/EditorSidebar.js +0 -22
@@ -1,16 +1,22 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
- import { simpleGit } from 'simple-git';
4
5
  import lockfile from 'proper-lockfile';
5
6
  import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
6
- import { createCanopyOctokit } from '../github-service.js';
7
- import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
8
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
7
+ import { resolveWorkerGitHubAuth } from './github-auth.js';
8
+ import { seedFromGitHub, unreachableFrom } from './github-transfer.js';
9
+ import { createLocalGitHubGateway, } from './github-gateway.js';
10
+ import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
11
+ import { readHeadBranch } from '../utils/git.js';
12
+ import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
9
13
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
10
14
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
11
- import { readLastFatalError, writeWorkerStatus } from '../task-queue/worker-status.js';
15
+ import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
12
16
  import { CANOPYCMS_VERSION } from '../version.js';
13
17
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
18
+ import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.js';
19
+ import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
14
20
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
15
21
  import { pollMergeState, runRebaseCycle } from './rebase.js';
16
22
  import { cleanupTrashedBranchDirs, pushSettingsBranches, refreshBaseBranchWorkspace, syncGit, } from './git-sync.js';
@@ -26,38 +32,229 @@ export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from '.
26
32
  // App builds the credential itself (core must not import `@octokit/auth-app` —
27
33
  // see github-auth.ts) and needs the shape to inject and the key normalizer to
28
34
  // apply.
29
- export { normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, } from './github-auth.js';
30
- const DEFAULT_TASK_TIMEOUT = 60_000;
35
+ export {
36
+ // The gateway process resolves its credential from its own config.
37
+ resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, } from './github-auth.js';
38
+ // Re-exported for the same reason: an entrypoint that runs the GitHub gateway and the worker as
39
+ // two processes serves the in-process gateway on a socket in one, and connects to it in the other.
40
+ export { createLocalGitHubGateway, } from './github-gateway.js';
41
+ export { serveGitHubGateway, connectGitHubGateway, } from './github-gateway-http.js';
42
+ export { GitHubGatewayUnreachableError } from './github-gateway-wire.js';
43
+ // A gateway that failed to boot still serves the instance operations.
44
+ export { GatewayInstance } from './gateway-instance.js';
45
+ // The gateway process's scrubbed log lines, and the exact scrubs for the secrets it holds that
46
+ // are not GitHub tokens (the auth provider's key, the App's private key). `redactEgress` scrubs
47
+ // the cause a failed boot hands the worker.
48
+ export { gatewayLog, gatewayLogWarn, gatewayLogError, registerSecretText, registerPrivateKey, redactEgress, } from './gateway-egress.js';
49
+ // The boot self-checks the split entrypoint runs in each role (.claude/future-tasks/resolved/
50
+ // worker-shared-repo-git-process-split.md §5).
51
+ export { assertSelfChecks, gatewaySelfChecks, workerSelfChecks, } from './self-checks.js';
52
+ /** Per workspace, so two workers on one host (tests, dev) never share a mirror. */
53
+ function defaultStateDirectory(workspacePath) {
54
+ const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
55
+ return path.join(os.tmpdir(), `canopycms-worker-${key}`);
56
+ }
57
+ /** How many refs a refusal to replace remote.git names before summarising the rest. */
58
+ const MAX_REFS_LISTED = 20;
59
+ /** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
60
+ class RemoteGitKeptError extends Error {
61
+ }
62
+ /**
63
+ * Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
64
+ * holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
65
+ * a replacement would discard unchecked, so it throws when git reports anything on stderr
66
+ * (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
67
+ * by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
68
+ * skipped without a warning, and names nothing to lose.
69
+ */
70
+ async function listRemoteGitRefs(gitDir) {
71
+ let stderr = '';
72
+ const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
73
+ .outputHandler((_command, _stdout, err) => {
74
+ err.on('data', (chunk) => {
75
+ stderr += String(chunk);
76
+ });
77
+ })
78
+ .raw(['for-each-ref', '--format=%(objectname) %(refname)']);
79
+ if (stderr.trim() !== '')
80
+ throw new Error(stderr.trim());
81
+ const refs = new Map();
82
+ for (const line of output.split('\n')) {
83
+ const space = line.indexOf(' ');
84
+ if (space === -1)
85
+ continue;
86
+ const ref = line.slice(space + 1);
87
+ if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
88
+ refs.set(ref, line.slice(0, space));
89
+ }
90
+ return refs;
91
+ }
92
+ function sameRefs(a, b) {
93
+ return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
94
+ }
95
+ /**
96
+ * The default `taskTimeoutMs`. Exported because a split worker's gateway client passes it as its
97
+ * `fetchInactivityMs` and the gateway uses it as its `timeoutMs`, as the in-process gateway does.
98
+ */
99
+ export const DEFAULT_TASK_TIMEOUT = 60_000;
31
100
  const DEFAULT_MAX_RETRIES = 3;
32
101
  const DEFAULT_LOCK_STALE_MS = 60_000;
33
102
  /**
34
- * CMS Worker daemon: the operations Lambda, which has no internet, cannot
35
- * perform -- draining the task queue, syncing the bare repo with GitHub,
36
- * rebasing branch workspaces, and refreshing the auth metadata cache through a
37
- * pluggable callback.
103
+ * 90s fits inside an EC2 spot interruption's two-minute notice with room to
104
+ * exit, and exceeds the default task timeout, so a task already running when
105
+ * the drain begins normally finishes or times out on its own.
106
+ */
107
+ export const DEFAULT_DRAIN_DEADLINE_MS = 90_000;
108
+ /**
109
+ * After aborting at the drain deadline, how long to let the aborted work
110
+ * unwind: this, or the deadline itself if that is shorter.
111
+ */
112
+ const ABORT_GRACE_MS = 5_000;
113
+ /** Whether every promise settles within `ms`. */
114
+ async function settlesWithin(operations, ms) {
115
+ let timer;
116
+ try {
117
+ return await Promise.race([
118
+ Promise.allSettled(operations).then(() => true),
119
+ new Promise((resolve) => {
120
+ timer = setTimeout(() => resolve(false), ms);
121
+ }),
122
+ ]);
123
+ }
124
+ finally {
125
+ clearTimeout(timer);
126
+ }
127
+ }
128
+ // Shown as the last shutdown's reason in System health, so it reads as a sentence there.
129
+ const LOCK_COMPROMISED_REASON = 'the worker lost its lock on the shared workspace';
130
+ /**
131
+ * Take the cross-host worker lock (see {@link CmsWorker}'s `acquireLock`), retrying for up to
132
+ * `waitMs`. Rejects with ELOCKED while another holder keeps it fresh.
133
+ */
134
+ function lockWorkerTaskDir(taskDir, lockStaleMs, options) {
135
+ const waitMs = options.waitMs ?? 0;
136
+ const intervalMs = Math.max(250, Math.min(5_000, Math.floor(lockStaleMs / 4)));
137
+ return lockfile.lock(taskDir, {
138
+ lockfilePath: path.join(taskDir, '.worker-lock'),
139
+ stale: lockStaleMs,
140
+ onCompromised: options.onCompromised,
141
+ ...(waitMs > 0
142
+ ? {
143
+ retries: {
144
+ retries: Math.ceil(waitMs / intervalMs),
145
+ factor: 1,
146
+ minTimeout: intervalMs,
147
+ maxTimeout: intervalMs,
148
+ },
149
+ }
150
+ : {}),
151
+ });
152
+ }
153
+ /**
154
+ * The fields a new status snapshot carries from the previous file: `lastFatalError` and
155
+ * `lastShutdown` (task-queue/worker-status.ts) and the schema gate's hold, so its bound keeps
156
+ * counting from the first worker that saw each schema missing.
157
+ */
158
+ async function readCarriedFields(taskDir) {
159
+ const { lastFatalError, lastShutdown } = await readCarriedOverStatus(taskDir);
160
+ const baseHold = await readCarriedBaseHold(taskDir);
161
+ return {
162
+ ...(lastFatalError ? { lastFatalError } : {}),
163
+ ...(lastShutdown ? { lastShutdown } : {}),
164
+ ...(baseHold ? { baseHold } : {}),
165
+ };
166
+ }
167
+ /**
168
+ * Record a worker that failed before {@link CmsWorker.start} as `lastFatalError` (phase
169
+ * `startup`), so System health and the API's not-ready answer say why. For an entrypoint's own
170
+ * boot steps (its environment, its secrets); start() records its own failures.
38
171
  *
39
- * Auth-agnostic (no specific auth provider) and cloud-agnostic (git/Octokit
40
- * directly, no AWS SDK dependency).
172
+ * Writes under the worker lock like every status write, and writes nothing while the lock looks
173
+ * held (fresher than twice `lockStaleMs`, see recordLockLoss): its holder owns the file. Never
174
+ * throws, since its caller is already exiting.
41
175
  */
176
+ export async function recordWorkerStartupFailure(options) {
177
+ const taskDir = path.join(options.workspacePath, '.tasks');
178
+ let release;
179
+ try {
180
+ await fs.mkdir(taskDir, { recursive: true });
181
+ // Twice the usual staleness, for the reason recordLockLoss gives: a lost record costs
182
+ // nothing, a removed live lock costs a worker.
183
+ release = await lockWorkerTaskDir(taskDir, 2 * (options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS), {
184
+ onCompromised: (err) => workerLogError('Worker lock lost while recording a startup failure:', getErrorMessage(err)),
185
+ });
186
+ const now = new Date().toISOString();
187
+ const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(taskDir);
188
+ if (lastFatalError &&
189
+ options.keepRecordedFatal &&
190
+ lastFatalError.workerStartedAt === (await readWorkerStatusStartedAt(taskDir)) &&
191
+ options.keepRecordedFatal(lastFatalError)) {
192
+ return;
193
+ }
194
+ await writeWorkerStatus(taskDir, {
195
+ version: 1,
196
+ workerVersion: CANOPYCMS_VERSION,
197
+ startedAt: now,
198
+ updatedAt: now,
199
+ ...(lastShutdown ? { lastShutdown } : {}),
200
+ ...(baseHold ? { baseHold } : {}),
201
+ lastFatalError: {
202
+ // [REDACT] Served to the browser by the admin panel and the API's not-ready answer.
203
+ message: redactCredentials(getErrorMessage(options.error)),
204
+ at: now,
205
+ phase: 'startup',
206
+ workerStartedAt: now,
207
+ },
208
+ });
209
+ }
210
+ catch (err) {
211
+ workerLogError(isNodeError(err) && err.code === 'ELOCKED'
212
+ ? 'Not recording the startup failure: another worker holds the lock and owns worker-status.json'
213
+ : `Failed to record the startup failure: ${getErrorMessage(err)}`);
214
+ }
215
+ finally {
216
+ await release?.().catch(() => { });
217
+ }
218
+ }
42
219
  export class CmsWorker {
43
220
  constructor(config) {
44
221
  this.config = config;
45
222
  this.activeTimeouts = new Set();
46
223
  this.running = false;
47
- this.activeOperations = new Set();
224
+ // In-flight loop iterations, each labelled for the drain's log lines and
225
+ // `lastShutdown.abandoned`.
226
+ this.activeOperations = new Map();
227
+ this.stopping = null;
228
+ // Aborted when a draining stop() reaches its deadline; see WorkerContext.
229
+ this.shutdownController = new AbortController();
48
230
  this.releaseLockFn = null;
49
231
  this.log = cmsTaskQueueLogger;
232
+ this.warnedDivergences = new Map();
233
+ /**
234
+ * Settles once the worker has stopped for a reason it chose itself (its lock was
235
+ * compromised), after the drain and its record in worker-status.json. Never settles for a
236
+ * stop() the entrypoint asked for. Nothing else ends the process, so an entrypoint exits
237
+ * non-zero on it and its process manager starts a fresh worker.
238
+ */
239
+ this.selfStopped = new Promise((resolve) => {
240
+ this.settleSelfStopped = resolve;
241
+ });
50
242
  this.taskDir = path.join(config.workspacePath, '.tasks');
51
- this.remoteGitPath = path.join(config.workspacePath, 'remote.git');
243
+ // Absolute: git can read a relative path such as `sub/remote.git` as a remote's name and use
244
+ // that remote's `remote.<name>.url` from a clone's config; never one that starts with '/'.
245
+ this.remoteGitPath = path.join(path.resolve(config.workspacePath), 'remote.git');
246
+ this.stateDirectory = path.resolve(config.stateDirectory ?? defaultStateDirectory(config.workspacePath));
52
247
  this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
53
- this.baseBranch = config.baseBranch ?? 'main';
54
- this.sanitizedBaseBranch = sanitizeBranchName(this.baseBranch);
248
+ if (config.baseBranch !== undefined)
249
+ this.setBaseBranch(config.baseBranch);
55
250
  this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
56
251
  this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
57
252
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
58
253
  this.lockFilePath = path.join(config.workspacePath, '.tasks', '.worker-lock');
59
254
  this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
255
+ this.drainDeadlineMs = config.drainDeadlineMs ?? DEFAULT_DRAIN_DEADLINE_MS;
60
256
  this.contentRoot = config.contentRoot ?? 'content';
257
+ this.schemaHoldMaxMs = config.schemaHoldMaxMs ?? DEFAULT_SCHEMA_HOLD_MAX_MS;
61
258
  }
62
259
  /**
63
260
  * This worker's self-reported status object, initialized on first use.
@@ -89,12 +286,12 @@ export class CmsWorker {
89
286
  * as foreign and never pushes it.
90
287
  *
91
288
  * DEFERRED out of the constructor deliberately, which is the whole point of
92
- * the method existing: canopycms-cdk/worker/index.ts constructs the worker
93
- * before calling start(), so a throw during `new CmsWorker(...)` lands BEFORE
94
- * the only code that writes `lastFatalError` -- start()'s catch. Under
95
- * systemd `Type=simple` + `Restart=always` with no cfn-signal, that is an
96
- * invisible ~5s crash-loop that `cdk deploy` reports as success while the
97
- * admin panel shows the worker 'absent' with no fatal error to explain it.
289
+ * the method existing: an entrypoint constructs the worker before calling
290
+ * start(), so a throw during `new CmsWorker(...)` lands BEFORE start()'s
291
+ * catch, which records `lastFatalError` for every entrypoint. Only one that
292
+ * calls `recordWorkerStartupFailure` (canopycms-cdk/worker/run.ts does) would
293
+ * record it otherwise; under systemd `Restart=always` the rest crash-loop
294
+ * every ~5s with System health showing the worker 'absent' and no reason.
98
295
  *
99
296
  * Lazy rather than start()-only so unit tests driving pushSettingsBranches()
100
297
  * still see the value, exactly as ensureStatusReport() above. Idempotent: the
@@ -115,56 +312,87 @@ export class CmsWorker {
115
312
  * Built FRESH on every call, with instance-backed members as functions rather
116
313
  * than copied values. See WorkerContext's INVARIANT: a context that captured
117
314
  * any of them at construction time would hand the extracted code the pre-test
118
- * value, which for `buildGitHubUrl` means a test's push going to github.com
119
- * for real instead of its local fixture repo.
315
+ * value, which for the GitHub gateway means a test's push going to
316
+ * github.com for real instead of its local fixture repo.
120
317
  */
121
318
  ctx() {
122
319
  return {
123
- githubOwner: this.config.githubOwner,
124
- githubRepo: this.config.githubRepo,
125
320
  baseBranch: this.baseBranch,
126
321
  sanitizedBaseBranch: this.sanitizedBaseBranch,
127
322
  taskDir: this.taskDir,
128
323
  remoteGitPath: this.remoteGitPath,
129
324
  contentBranchesPath: this.contentBranchesPath,
130
325
  contentRoot: this.contentRoot,
326
+ schemaHoldMaxMs: this.schemaHoldMaxMs,
131
327
  taskTimeoutMs: this.taskTimeoutMs,
132
328
  maxTasksPerCycle: this.maxTasksPerCycle,
133
329
  maxRetries: this.maxRetries,
134
330
  log: this.log,
135
- octokit: () => this.octokitClient(),
136
- buildGitHubUrl: () => this.buildGitHubUrl(),
137
- refreshGitHubCredential: () => this.refreshGitHubCredential(),
331
+ github: () => this.github(),
138
332
  branchWorkspacePath: (branchRefName) => this.branchWorkspacePath(branchRefName),
139
333
  executeTask: (task, signal) => this.executeTask(task, signal),
140
- pushBranchToGitHub: (branch) => this.pushBranchToGitHub(branch),
334
+ pushBranchToGitHub: (branch, signal) => this.pushBranchToGitHub(branch, signal),
141
335
  isRunning: () => this.running,
336
+ isDraining: () => this.stopping !== null,
337
+ shutdownSignal: () => this.shutdownController.signal,
142
338
  ensureStatusReport: () => this.ensureStatusReport(),
143
339
  ensureSettingsBranch: () => this.ensureSettingsBranch(),
340
+ warnedDivergences: () => this.warnedDivergences,
144
341
  afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
145
342
  afterRebaseCompletedForTesting: () => this.afterRebaseCompletedForTesting(),
146
343
  };
147
344
  }
148
- async start() {
345
+ /**
346
+ * Take the worker lock, provision, run the first sync, then schedule the
347
+ * loops. All of it is tracked as an operation, so a stop() that lands
348
+ * mid-startup -- during the lock acquisition, a first-boot clone, the first
349
+ * sync -- drains it before releasing the lock, and start() then resolves
350
+ * without scheduling anything.
351
+ */
352
+ start() {
353
+ const starting = this.startUnderLock();
354
+ this.trackOperation('startup', starting);
355
+ return starting;
356
+ }
357
+ async startUnderLock() {
358
+ // A stop() that already finished has nothing left to release a lock taken now.
359
+ if (this.stopping)
360
+ return;
149
361
  this.running = true;
150
362
  workerLog('CMS Worker starting...');
363
+ // Each attempt is a new worker to worker-status.json: a start() retried after a failed one
364
+ // must not match that failure's `workerStartedAt` (see readCarriedOverStatus).
365
+ this.statusReport = undefined;
151
366
  this.ensureStatusReport();
152
367
  await this.acquireLock();
153
368
  // Replace the previous holder's status file now: the first sync can take
154
369
  // minutes, and until then System health would report the old worker's
155
370
  // version. Best-effort, like the startup-failure write below. The previous
156
371
  // `lastFatalError` rides along in this snapshot only, so a crash loop keeps
157
- // its alert between restarts while the first successful sync still clears it.
372
+ // its alert between restarts while the first successful sync still clears
373
+ // it. Two fields carry into the report itself: `lastShutdown`, which
374
+ // describes the last worker that ran until this worker's own stop() replaces it,
375
+ // and the schema gate's hold, so its bound keeps counting from the first
376
+ // worker that saw each schema missing.
158
377
  try {
159
- const lastFatalError = await readLastFatalError(this.taskDir);
378
+ const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(this.taskDir);
379
+ const report = this.ensureStatusReport();
380
+ if (lastShutdown)
381
+ report.lastShutdown = lastShutdown;
382
+ if (baseHold)
383
+ report.baseHold = baseHold;
160
384
  await writeWorkerStatus(this.taskDir, {
161
- ...this.ensureStatusReport(),
385
+ ...report,
162
386
  ...(lastFatalError ? { lastFatalError } : {}),
163
387
  });
164
388
  }
165
389
  catch (err) {
166
390
  workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
167
391
  }
392
+ // After the carry-over, not before: a stop() during the lock acquisition
393
+ // writes this report, which must still hold what the previous worker left.
394
+ if (this.stopping)
395
+ return;
168
396
  // Everything below runs while holding the cross-host worker lock. A failure
169
397
  // here (most notably the empty-remote guard inside ensureRemoteGit) means
170
398
  // the process is about to exit and systemd (Restart=always) will retry —
@@ -181,12 +409,19 @@ export class CmsWorker {
181
409
  // Same shape, same reason: a half-configured credential throws HERE,
182
410
  // inside the try, rather than out of `new CmsWorker(...)` where nothing
183
411
  // could record it.
184
- this.ensureGitHubAuth();
412
+ const github = this.github();
413
+ // Same again. NaN would never expire a schema hold, which is the one
414
+ // thing the bound exists to prevent.
415
+ if (!Number.isFinite(this.schemaHoldMaxMs) || this.schemaHoldMaxMs < 0) {
416
+ throw new Error(`CmsWorker: schemaHoldMaxMs must be a finite, non-negative number of milliseconds (got ${this.schemaHoldMaxMs})`);
417
+ }
185
418
  // BEFORE ensureRemoteGit(): its clone is the first thing to use the
186
419
  // credential, and its catch blames the repository rather than the
187
- // credential. See preflightGitHubAppAuth().
188
- await this.preflightGitHubAppAuth();
420
+ // credential. See GitHubGateway.prepare().
421
+ await github.prepare();
422
+ await this.resolveBaseBranch();
189
423
  await this.ensureRemoteGit();
424
+ await this.recordBaseBranchInRemoteHead();
190
425
  // Recover orphaned tasks immediately rather than waiting for the first
191
426
  // processTaskQueue() poll. Not the only call site: processTaskQueue()
192
427
  // repeats this every cycle, and its doc comment says why a boot-only call
@@ -195,13 +430,22 @@ export class CmsWorker {
195
430
  if (recovered > 0) {
196
431
  workerLog(`Recovered ${recovered} orphaned task(s)`);
197
432
  }
198
- const initialTasks = [this.syncGit()];
433
+ // Tracked like loop iterations, so a stop() during the first sync drains it.
434
+ const initialTasks = [
435
+ this.trackOperation('git sync', this.syncGit()),
436
+ this.trackOperation('base branch check', this.warnIfBaseUnprotected()),
437
+ ];
199
438
  if (this.config.refreshAuthCache) {
200
- initialTasks.push(this.refreshAuthCache());
439
+ initialTasks.push(this.trackOperation('auth cache refresh', this.refreshAuthCache()));
201
440
  }
202
441
  await Promise.allSettled(initialTasks);
203
442
  }
204
443
  catch (err) {
444
+ // A drain cut startup short: stop() owns the lock and the status file now.
445
+ if (this.stopping) {
446
+ workerLog(`Startup ended by the drain: ${redactCredentials(getErrorMessage(err))}`);
447
+ return;
448
+ }
205
449
  // Surface a startup failure (e.g. the empty-remote guard's poisoned
206
450
  // remote.git) to the admin panel via worker-status.json, not only
207
451
  // journald/CloudWatch. Best-effort and BEFORE releaseLock(): a
@@ -210,10 +454,11 @@ export class CmsWorker {
210
454
  report.lastFatalError = {
211
455
  // [REDACT] Persisted to worker-status.json and served to the browser by
212
456
  // the admin panel -- must never carry the bot token a poisoned or
213
- // failed git URL (buildGitHubUrl()) can embed.
457
+ // failed git URL (the gateway's) can embed.
214
458
  message: redactCredentials(getErrorMessage(err)),
215
459
  at: new Date().toISOString(),
216
460
  phase: 'startup',
461
+ workerStartedAt: report.startedAt,
217
462
  };
218
463
  try {
219
464
  await writeWorkerStatus(this.taskDir, report);
@@ -224,39 +469,95 @@ export class CmsWorker {
224
469
  await this.releaseLock();
225
470
  throw err;
226
471
  }
472
+ if (this.stopping)
473
+ return;
227
474
  const taskInterval = this.config.taskPollInterval ?? 5_000;
228
475
  const gitInterval = this.config.gitSyncInterval ?? 5 * 60_000;
229
- this.scheduleLoop(() => this.processTaskQueue(), taskInterval);
230
- // The wrapper, not syncGit() itself: a failed sync is where a rotated or
231
- // revoked GitHub credential is noticed. start()'s own initial syncGit()
232
- // above stays unwrapped -- there is no stale credential to refresh one line
233
- // after reading it at boot.
234
- this.scheduleLoop(() => this.syncGitWithCredentialRefresh(), gitInterval);
476
+ this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
477
+ this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
235
478
  if (this.config.refreshAuthCache) {
236
479
  const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
237
- this.scheduleLoop(() => this.refreshAuthCache(), cacheInterval);
480
+ this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
238
481
  workerLog(` Auth cache refresh: every ${cacheInterval / 1000}s`);
239
482
  }
240
483
  workerLog('CMS Worker started');
241
484
  workerLog(` Task queue poll: every ${taskInterval / 1000}s`);
242
485
  workerLog(` Git sync: every ${gitInterval / 1000}s`);
243
486
  }
244
- async stop() {
487
+ /**
488
+ * Drain, then release the worker lock. Idempotent: a second call returns the
489
+ * first call's promise.
490
+ *
491
+ * 1. Stop taking work: no loop starts another iteration, and none claims
492
+ * another task or starts another sync stage or branch rebase (each checks
493
+ * `isDraining()` at its boundaries).
494
+ * 2. Wait up to the deadline (`drainDeadlineMs`, or `deadlineMs` for this
495
+ * call) for what is already in flight, startup included.
496
+ * 3. At the deadline, abort it through `shutdownSignal()`: a task's git push
497
+ * and GitHub calls are killed and the task is released to pending with no
498
+ * retry spent; a sync's bundle, unbundle, ref update and settings push
499
+ * are killed. The gateway's GitHub fetches and mirror maintenance run on
500
+ * (safeinsights/canopycms#907). A branch rebase
501
+ * is never aborted, because a rebase killed mid-branch is recovered
502
+ * lossily; anything still unsettled after a short grace is abandoned to
503
+ * the process exit.
504
+ * 4. Record `lastShutdown` in worker-status.json -- only while this worker
505
+ * still holds the lock, or after a compromise that started this stop once
506
+ * it has retaken it (recordLockLoss) -- and release the lock. Release comes last, so a successor starts
507
+ * only once this worker's work has settled or been abandoned.
508
+ */
509
+ stop(options = {}) {
510
+ // A later call cannot restart the drain, but deadline 0 (a lock compromise)
511
+ // still cuts the running one short.
512
+ if (this.stopping && options.deadlineMs === 0)
513
+ this.shutdownController.abort();
514
+ this.stopping ??= this.drainAndStop(options.reason ?? 'stop requested', options.deadlineMs ?? this.drainDeadlineMs);
515
+ return this.stopping;
516
+ }
517
+ async drainAndStop(reason, deadlineMs) {
518
+ const startedAt = Date.now();
245
519
  this.running = false;
246
520
  for (const t of this.activeTimeouts) {
247
521
  clearTimeout(t);
248
522
  }
249
523
  this.activeTimeouts.clear();
250
- let drainTimer;
251
- await Promise.race([
252
- Promise.allSettled([...this.activeOperations]),
253
- new Promise((r) => {
254
- drainTimer = setTimeout(r, this.taskTimeoutMs);
255
- }),
256
- ]);
257
- clearTimeout(drainTimer);
524
+ const inFlight = [...this.activeOperations.values()];
525
+ workerLog(inFlight.length > 0
526
+ ? `Draining (${reason}): waiting up to ${deadlineMs / 1000}s for ${inFlight.join(', ')}`
527
+ : `Draining (${reason}): nothing in flight`);
528
+ let abandoned = [];
529
+ if (!(await settlesWithin([...this.activeOperations.keys()], deadlineMs))) {
530
+ abandoned = [...new Set(this.activeOperations.values())];
531
+ workerLogWarn(`Drain deadline (${deadlineMs / 1000}s) hit, aborting: ${abandoned.join(', ')}`);
532
+ this.shutdownController.abort();
533
+ const graceMs = Math.min(ABORT_GRACE_MS, this.drainDeadlineMs);
534
+ if (!(await settlesWithin([...this.activeOperations.keys()], graceMs))) {
535
+ workerLogError(`Still running ${graceMs / 1000}s after the abort, abandoned to the exit: ${[
536
+ ...new Set(this.activeOperations.values()),
537
+ ].join(', ')}`);
538
+ }
539
+ }
540
+ const drainMs = Date.now() - startedAt;
541
+ const shutdown = {
542
+ reason,
543
+ at: new Date().toISOString(),
544
+ workerStartedAt: this.ensureStatusReport().startedAt,
545
+ outcome: abandoned.length > 0 ? 'deadline' : 'drained',
546
+ drainMs,
547
+ ...(abandoned.length > 0 ? { abandoned } : {}),
548
+ };
549
+ if (this.releaseLockFn) {
550
+ const report = this.ensureStatusReport();
551
+ report.lastShutdown = shutdown;
552
+ await writeWorkerStatus(this.taskDir, report).catch((err) => workerLogError('Failed to write worker status on shutdown:', getErrorMessage(err)));
553
+ }
554
+ else if (this.lockLostMessage !== undefined) {
555
+ await this.recordLockLoss(shutdown, this.lockLostMessage);
556
+ }
258
557
  await this.releaseLock();
259
- workerLog('CMS Worker stopped');
558
+ workerLog(abandoned.length > 0
559
+ ? `CMS Worker stopped after the drain deadline (${(drainMs / 1000).toFixed(1)}s)`
560
+ : `CMS Worker stopped: drained in ${(drainMs / 1000).toFixed(1)}s`);
260
561
  }
261
562
  /**
262
563
  * Acquire the cross-host worker lock (DEP-C2).
@@ -283,16 +584,22 @@ export class CmsWorker {
283
584
  async acquireLock() {
284
585
  await fs.mkdir(this.taskDir, { recursive: true });
285
586
  try {
286
- this.releaseLockFn = await lockfile.lock(this.taskDir, {
287
- lockfilePath: this.lockFilePath,
288
- stale: this.lockStaleMs,
587
+ this.releaseLockFn = await lockWorkerTaskDir(this.taskDir, this.lockStaleMs, {
289
588
  onCompromised: (err) => {
290
589
  // The heartbeat could not be maintained (lock deleted or taken over),
291
- // so another worker may now be consuming the queue: stop processing
292
- // to preserve the single-consumer invariant.
590
+ // so another worker may now be consuming the queue: abort at once,
591
+ // with no drain, to restore the single-consumer invariant. Unless a
592
+ // stop was already under way, this worker chose to stop, which
593
+ // selfStopped reports once the drain has recorded it.
293
594
  workerLogError('Worker lock compromised, shutting down:', getErrorMessage(err));
294
595
  this.releaseLockFn = null; // the lock is already lost; nothing to release
295
- void this.stop();
596
+ const selfStop = this.stopping === null;
597
+ if (selfStop)
598
+ this.lockLostMessage = redactCredentials(getErrorMessage(err));
599
+ const stopped = this.stop({ reason: LOCK_COMPROMISED_REASON, deadlineMs: 0 });
600
+ if (selfStop) {
601
+ void stopped.then(() => this.settleSelfStopped({ reason: LOCK_COMPROMISED_REASON }));
602
+ }
296
603
  },
297
604
  });
298
605
  }
@@ -302,6 +609,7 @@ export class CmsWorker {
302
609
  }
303
610
  throw err;
304
611
  }
612
+ this.statusWriterAtLock = await readWorkerStatusStartedAt(this.taskDir).catch(() => undefined);
305
613
  }
306
614
  async releaseLock() {
307
615
  const release = this.releaseLockFn;
@@ -315,12 +623,67 @@ export class CmsWorker {
315
623
  // Lock already released or compromised
316
624
  }
317
625
  }
626
+ /**
627
+ * Record a lock compromise in worker-status.json, under the lock: retake it, waiting out its
628
+ * staleness window, and write only if that succeeds and the file is still this worker's (or
629
+ * the one it took the lock from). A worker that took the lock over keeps it fresh, or has
630
+ * written the file since, and owns it, so nothing is written. A heartbeat this worker merely
631
+ * failed to refresh (an EFS hiccup) goes stale, and the record lands where the restarted
632
+ * worker carries `lastShutdown` forward. The restart waits for it: 65 s at the default `lockStaleMs`.
633
+ */
634
+ async recordLockLoss(shutdown, message) {
635
+ let release;
636
+ try {
637
+ // Twice the usual staleness: a successor's live heartbeat can look a refresh interval
638
+ // plus an EFS attribute-cache window old from here, and taking it would remove its lock.
639
+ release = await lockWorkerTaskDir(this.taskDir, this.lockStaleMs * 2, {
640
+ // A refresh failure fires the compromise once this worker's own lock has gone
641
+ // unrefreshed for `lockStaleMs`, or up to a second less (proper-lockfile rounds the
642
+ // first mtime up). So the wait covers the remaining `lockStaleMs`, that second and a
643
+ // retry interval, and stays inside the unit's TimeoutStopSec when a SIGTERM lands
644
+ // mid-wait. A deleted lock is retaken at once; a foreign mtime is a successor's.
645
+ waitMs: this.lockStaleMs + 2_000,
646
+ onCompromised: (err) => workerLogError('Worker lock lost again while recording the loss:', getErrorMessage(err)),
647
+ });
648
+ const report = this.ensureStatusReport();
649
+ // A successor that took the lock and released it during the wait owns the file now.
650
+ const writtenBy = await readWorkerStatusStartedAt(this.taskDir);
651
+ if (writtenBy !== undefined &&
652
+ writtenBy !== report.startedAt &&
653
+ writtenBy !== this.statusWriterAtLock) {
654
+ workerLogError('Not recording the lock loss: another worker has written worker-status.json since');
655
+ return;
656
+ }
657
+ report.lastShutdown = shutdown;
658
+ report.lastFatalError = {
659
+ message: `The worker lost its lock on the shared workspace and stopped; it restarts on its own. ${message}`,
660
+ at: shutdown.at,
661
+ phase: 'run',
662
+ workerStartedAt: report.startedAt,
663
+ };
664
+ await writeWorkerStatus(this.taskDir, report);
665
+ }
666
+ catch (err) {
667
+ workerLogError(isNodeError(err) && err.code === 'ELOCKED'
668
+ ? 'Not recording the lock loss: another worker holds the lock and owns worker-status.json'
669
+ : `Failed to record the lock loss: ${getErrorMessage(err)}`);
670
+ }
671
+ finally {
672
+ await release?.().catch(() => { });
673
+ }
674
+ }
675
+ /** Register `operation` as in flight under `label` until it settles. */
676
+ trackOperation(label, operation) {
677
+ this.activeOperations.set(operation, label);
678
+ void operation.finally(() => this.activeOperations.delete(operation)).catch(() => { });
679
+ return operation;
680
+ }
318
681
  /**
319
682
  * Run `fn` repeatedly, the next invocation starting `interval` ms after the
320
683
  * previous one COMPLETES. setTimeout chaining rather than setInterval, so
321
684
  * executions cannot overlap when one runs longer than the interval.
322
685
  */
323
- scheduleLoop(fn, interval) {
686
+ scheduleLoop(label, fn, interval) {
324
687
  const run = () => {
325
688
  if (!this.running)
326
689
  return;
@@ -329,22 +692,139 @@ export class CmsWorker {
329
692
  const operation = fn().catch((err) => {
330
693
  workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
331
694
  });
332
- this.activeOperations.add(operation);
333
- operation.finally(() => this.activeOperations.delete(operation));
334
- await operation;
695
+ await this.trackOperation(label, operation);
335
696
  run();
336
697
  }, interval);
337
698
  this.activeTimeouts.add(timeout);
338
699
  };
339
700
  run();
340
701
  }
702
+ get baseBranch() {
703
+ return this.requireBaseBranch().name;
704
+ }
705
+ get sanitizedBaseBranch() {
706
+ return this.requireBaseBranch().sanitized;
707
+ }
708
+ requireBaseBranch() {
709
+ if (!this.resolvedBaseBranch) {
710
+ throw new Error('CmsWorker: the base branch is resolved by start(); it has not run yet');
711
+ }
712
+ return this.resolvedBaseBranch;
713
+ }
714
+ setBaseBranch(name) {
715
+ this.resolvedBaseBranch = { name, sanitized: sanitizeBranchName(name) };
716
+ }
717
+ /**
718
+ * An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
719
+ * reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
720
+ * branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
721
+ * outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
722
+ * replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
723
+ */
724
+ async resolveBaseBranch() {
725
+ if (this.resolvedBaseBranch)
726
+ return;
727
+ const undetermined = (err) => new Error(`CANOPYCMS_BASE_BRANCH is not set (the CDK construct's \`baseBranch\` prop), and the ` +
728
+ `base branch could not be determined from ${this.remoteGitPath} or GitHub: ` +
729
+ `${redactCredentials(getErrorMessage(err))}. Set it to the branch editing branches fork ` +
730
+ `from, or, if remote.git is damaged, delete ${this.remoteGitPath} and restart to re-clone.`);
731
+ let remoteGitExists;
732
+ try {
733
+ remoteGitExists = await fs.stat(this.remoteGitPath).then(() => true, (err) => {
734
+ if (isNodeError(err) && err.code === 'ENOENT')
735
+ return false;
736
+ throw err;
737
+ });
738
+ }
739
+ catch (err) {
740
+ throw undetermined(err);
741
+ }
742
+ // Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
743
+ // base branch to configure or a remote.git to delete.
744
+ if (remoteGitExists)
745
+ await this.assertRemoteGitConfig();
746
+ let name;
747
+ try {
748
+ const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
749
+ name = fromRemoteGit
750
+ ? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
751
+ : await this.github().defaultBranch();
752
+ }
753
+ catch (err) {
754
+ throw undetermined(err);
755
+ }
756
+ this.setBaseBranch(name);
757
+ workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
758
+ }
759
+ /**
760
+ * Warn when the base branch is outside the gateway's protected set: it was detected from
761
+ * remote.git rather than configured, and is not GitHub's default branch at boot. Only the
762
+ * worker's own check (`pushBranchToGitHub`) then stops a push to it. A warning, not a refusal, so
763
+ * a change of GitHub's default branch cannot stop the worker booting.
764
+ */
765
+ async warnIfBaseUnprotected() {
766
+ try {
767
+ if (await this.github().isProtectedBranch(this.baseBranch, this.shutdownController.signal)) {
768
+ return;
769
+ }
770
+ workerLogWarn(`The base branch '${this.baseBranch}' is neither CANOPYCMS_BASE_BRANCH nor GitHub's ` +
771
+ `default branch, nor in CANOPYCMS_PROTECTED_BRANCHES, so the GitHub gateway's policy ` +
772
+ `does not protect it; only the worker's own check stops a push to it. Set ` +
773
+ `CANOPYCMS_BASE_BRANCH (the CDK construct's \`baseBranch\` prop).`);
774
+ }
775
+ catch (err) {
776
+ workerLogWarn(`Could not check that the base branch '${this.baseBranch}' is protected: ` +
777
+ redactCredentials(getErrorMessage(err)));
778
+ }
779
+ }
780
+ /**
781
+ * Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
782
+ * something wrote there, and listing the refs a replacement would lose means running git under
783
+ * that config. The message says so, so System health does not read it as a routine failure.
784
+ */
785
+ async assertRemoteGitConfig() {
786
+ try {
787
+ await assertSharedRepoConfig(this.remoteGitPath, 'bare');
788
+ }
789
+ catch (err) {
790
+ if (!(err instanceof SharedRepoRefusalError))
791
+ throw err;
792
+ throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
793
+ `replaces a remote.git whose config was tampered with or cannot be read.`);
794
+ }
795
+ }
796
+ /** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
797
+ async hasContentBranch(gitDir) {
798
+ const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
799
+ 'for-each-ref',
800
+ '--format=%(refname:strip=2)',
801
+ 'refs/heads/',
802
+ ]);
803
+ return branches
804
+ .split('\n')
805
+ .some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
806
+ }
807
+ /**
808
+ * Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
809
+ * (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
810
+ * the worker lock, and in a fresh clone before it is renamed into place, so no Lambda reads the
811
+ * clone's GitHub-default HEAD first; a Lambda reads it once per process.
812
+ */
813
+ async recordBaseBranchInRemoteHead(gitDir = this.remoteGitPath) {
814
+ const ref = `refs/heads/${this.baseBranch}`;
815
+ const current = await readHeadBranch(gitDir, sharedRepoGit(gitDir, 'bare')).catch(() => undefined);
816
+ if (current === this.baseBranch)
817
+ return;
818
+ // Pinned: moving HEAD fires remote.git's reference-transaction hooks.
819
+ await sharedRepoGit(gitDir, 'bare').raw(['symbolic-ref', 'HEAD', ref]);
820
+ workerLog(`${path.basename(gitDir)} HEAD now names the base branch '${this.baseBranch}'`);
821
+ }
341
822
  /**
342
823
  * Whether the bare repo at `gitDir` has a local `refs/heads/<baseBranch>`.
343
824
  *
344
- * Explicit `--git-dir` rather than `simpleGit({ baseDir })`, so this also
825
+ * The repository is named outright (sharedRepoGit's GIT_DIR), so this also
345
826
  * works where `safe.bareRepository=explicit` refuses cwd-based discovery of
346
- * bare repos but expressly allows `--git-dir` (same pattern as
347
- * GitManager.bareRemoteHasBranch).
827
+ * bare repos.
348
828
  *
349
829
  * Deliberately omits `--quiet`: simple-git treats a task as failed only when
350
830
  * the process exits non-zero AND writes to stderr, so a silent-on-failure
@@ -353,9 +833,7 @@ export class CmsWorker {
353
833
  * which is what makes simple-git reject the promise here.
354
834
  */
355
835
  async verifyBaseBranchExists(gitDir) {
356
- await simpleGit().raw([
357
- '--git-dir',
358
- gitDir,
836
+ await sharedRepoGit(gitDir, 'bare').raw([
359
837
  'rev-parse',
360
838
  '--verify',
361
839
  `refs/heads/${this.baseBranch}`,
@@ -368,8 +846,8 @@ export class CmsWorker {
368
846
  * `git clone https://x-access-token:<token>@github.com/...` records that URL
369
847
  * verbatim as `remote.origin.url`, and for `remote.git` that config lives on
370
848
  * shared EFS. The security model in docs/deploying-to-aws.md -- a compromised
371
- * Lambda can read/write EFS content but cannot push to GitHub, secrets stay
372
- * on the worker -- is false while that string is there: the Lambda could read
849
+ * Lambda can read/write EFS content but cannot reach the GitHub credential,
850
+ * which stays off EFS -- is false while that string is there: the Lambda could read
373
851
  * the token off EFS and, with no egress of its own, exfiltrate it by writing
374
852
  * it into branch content the worker then pushes to GitHub.
375
853
  *
@@ -380,7 +858,7 @@ export class CmsWorker {
380
858
  * survives, so a failed scrub is never indistinguishable from a clean one.
381
859
  */
382
860
  async scrubPersistedRemote(gitDir) {
383
- const git = simpleGit({ baseDir: gitDir });
861
+ const git = sharedRepoGit(gitDir, 'bare');
384
862
  // `git config --get` exits 1 with no output when the key is absent, and
385
863
  // simple-git resolves with an empty string rather than throwing (verified
386
864
  // against 3.36), so an empty result means "absent" too.
@@ -433,17 +911,31 @@ export class CmsWorker {
433
911
  }
434
912
  }
435
913
  /**
436
- * Ensure the remote.git bare repo exists, cloning it from GitHub on first
437
- * run.
914
+ * Best-effort: without these settings remote.git still works, and a boot that
915
+ * cannot write its own config has a louder problem to report elsewhere.
916
+ */
917
+ async applyRemoteGitConfig(gitDir) {
918
+ try {
919
+ await ensureRemoteGitConfig(gitDir, sharedRepoGit(gitDir, 'bare'));
920
+ }
921
+ catch (err) {
922
+ workerLogWarn(`Could not apply remote.git config in ${gitDir}: ${getErrorMessage(err)}`);
923
+ }
924
+ }
925
+ /**
926
+ * Ensure the remote.git bare repo exists, seeding it from GitHub on first run.
927
+ *
928
+ * Seeded through the GitHub gateway: it fetches from GitHub, and a fresh bare repo takes its
929
+ * branches from the bundle it answers with (worker/github-transfer.ts), so no git command run
930
+ * against remote.git ever carries the credential, and no token-bearing URL is ever written to
931
+ * EFS.
438
932
  *
439
- * Empty-remote guard: simple-git's bare clone of an EMPTY GitHub repo (no
440
- * commits, or a base branch never pushed) exits 0 and produces a refs-less
441
- * bare repo whose HEAD points at an unborn branch. `fs.stat` cannot tell that
442
- * from a healthy clone, so left unchecked it silently poisons remote.git —
443
- * every later branch operation breaks and the fs.stat short-circuit means it
444
- * never heals. The base branch is therefore verified right after cloning AND
445
- * on the already-exists fast path, since a previous run can have left a
446
- * poisoned remote.git behind.
933
+ * Empty-remote guard: a remote.git with no base branch (one cloned from an EMPTY GitHub repo,
934
+ * or with a base branch never pushed) is one `fs.stat` cannot tell from a healthy one, so left
935
+ * unchecked it silently poisons remote.git and the stat short-circuit means it never heals. The
936
+ * seed refuses a GitHub without the base branch, and the base branch is verified right after
937
+ * seeding AND on the already-exists fast path, where a poisoned remote.git is replaced
938
+ * (`replacePoisonedRemoteGit`).
447
939
  */
448
940
  async ensureRemoteGit() {
449
941
  let exists;
@@ -455,51 +947,172 @@ export class CmsWorker {
455
947
  exists = false;
456
948
  }
457
949
  if (exists) {
458
- // SELF-HEAL, before anything else touches this repo: re-checked on every
459
- // boot, not only at clone time, so a token that survived one scrub does
460
- // not survive forever, and a clone interrupted between `git clone` and
461
- // the scrub cannot leave a token-bearing config sitting on EFS until an
462
- // operator acts.
950
+ // At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
951
+ // scrub is git reading this config too.
952
+ await this.assertRemoteGitConfig();
953
+ // SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
954
+ // older worker recorded the token-bearing clone URL in its config.
463
955
  await this.scrubPersistedRemote(this.remoteGitPath);
464
956
  try {
465
957
  await this.verifyBaseBranchExists(this.remoteGitPath);
466
958
  }
467
959
  catch (err) {
468
960
  workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
469
- // Do NOT auto-delete: an existing remote.git can hold unpushed
470
- // canopycms-settings-* branches or other state worth preserving, so
471
- // deletion is the operator's call.
472
- throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
961
+ await this.replacePoisonedRemoteGit();
962
+ return;
473
963
  }
964
+ await this.applyRemoteGitConfig(this.remoteGitPath);
474
965
  return; // Already exists and has the base branch
475
966
  }
476
967
  workerLog('Initializing remote.git from GitHub...');
477
- const git = simpleGit();
478
- // Clone under a TEMP name and rename into place only once the token is
479
- // scrubbed and the repo verified, so `remote.git` never exists on EFS in a
480
- // token-bearing state. A crash mid-clone leaves only this staging
481
- // directory, which the next boot deletes, rather than a poisoned
968
+ const stagingPath = await this.seedRemoteGitStaging();
969
+ await fs.rename(stagingPath, this.remoteGitPath);
970
+ workerLog('remote.git initialized');
971
+ }
972
+ /**
973
+ * Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
974
+ * when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
975
+ * a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
976
+ * naming the refs at stake: unpushed work such as a settings branch.
977
+ *
978
+ * The refs are checked against the replacement itself, once it is seeded: a ref's commit counts
979
+ * as on GitHub only when the seed holds it, reachable from a branch GitHub had, so the decision
980
+ * follows exactly what would replace remote.git, whatever the gateway fetches meanwhile. They are
981
+ * listed again just before the swap, since the Lambda can push into remote.git while the seed
982
+ * runs. The swap is two renames, so the name remote.git resolves to the old repo,
983
+ * the new one, or for an instant nothing, never a half-built or half-deleted one.
984
+ */
985
+ async replacePoisonedRemoteGit() {
986
+ const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
987
+ `GitHub repository was empty, or the base branch is set to one it never had)`;
988
+ const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
989
+ let refs;
990
+ try {
991
+ refs = await listRemoteGitRefs(this.remoteGitPath);
992
+ }
993
+ catch (err) {
994
+ throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
995
+ `the worker cannot tell whether replacing it would lose work. Deleting ` +
996
+ `${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
997
+ }
998
+ // Only a base branch the listing confirms absent: a check that failed for any other reason
999
+ // is no evidence the repo is poisoned.
1000
+ if (refs.has(`refs/heads/${this.baseBranch}`)) {
1001
+ throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
1002
+ `verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
1003
+ }
1004
+ workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
1005
+ const stagingPath = await this.seedRemoteGitStaging(async (staging, githubBranches) => {
1006
+ const missing = await unreachableFrom(this.transferContext(), staging, [...refs.values()], [...githubBranches.values()]);
1007
+ const unpushed = [];
1008
+ for (const [ref, id] of refs) {
1009
+ if (missing.has(id))
1010
+ unpushed.push(ref);
1011
+ }
1012
+ if (unpushed.length === 0)
1013
+ return;
1014
+ const shown = unpushed.slice(0, MAX_REFS_LISTED);
1015
+ const more = unpushed.length - shown.length;
1016
+ throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
1017
+ `GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
1018
+ `${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
1019
+ `the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
1020
+ }).catch((err) => {
1021
+ if (err instanceof RemoteGitKeptError)
1022
+ throw err;
1023
+ throw notReplaced(redactCredentials(getErrorMessage(err)));
1024
+ });
1025
+ const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
1026
+ let current;
1027
+ try {
1028
+ current = await listRemoteGitRefs(this.remoteGitPath);
1029
+ }
1030
+ catch (err) {
1031
+ await discardStaging();
1032
+ throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
1033
+ `worker checks again.`);
1034
+ }
1035
+ if (!sameRefs(refs, current)) {
1036
+ await discardStaging();
1037
+ throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
1038
+ }
1039
+ const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
1040
+ try {
1041
+ await fs.rename(this.remoteGitPath, replaced);
1042
+ }
1043
+ catch (err) {
1044
+ await discardStaging();
1045
+ throw err;
1046
+ }
1047
+ try {
1048
+ await fs.rename(stagingPath, this.remoteGitPath);
1049
+ }
1050
+ catch (err) {
1051
+ await fs.rename(replaced, this.remoteGitPath);
1052
+ await discardStaging();
1053
+ throw err;
1054
+ }
1055
+ // A push that resolved remote.git before the rename can still land in the old repo. One that
1056
+ // lands before this listing keeps it; a later one is lost from remote.git, though the clone
1057
+ // that pushed still has its commit.
1058
+ const after = await listRemoteGitRefs(replaced).catch(() => null);
1059
+ if (after !== null && sameRefs(refs, after)) {
1060
+ // remote.git is already replaced, so a push still writing here costs only this directory.
1061
+ await fs
1062
+ .rm(replaced, { recursive: true, force: true, maxRetries: 3 })
1063
+ .catch((err) => {
1064
+ workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
1065
+ });
1066
+ }
1067
+ else {
1068
+ workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
1069
+ `it may hold work GitHub does not have.`);
1070
+ }
1071
+ workerLog('remote.git replaced');
1072
+ }
1073
+ /**
1074
+ * Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
1075
+ * return its path for the caller to rename into place. `check` runs on the seeded repo, given
1076
+ * GitHub's branches as the seed holds them, and its throw stops the seed. Throws with the
1077
+ * staging directory gone.
1078
+ */
1079
+ async seedRemoteGitStaging(check) {
1080
+ // Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
1081
+ // leaves only this staging directory, which the next boot deletes, rather than a poisoned
482
1082
  // `remote.git` that fs.stat cannot distinguish from a healthy one.
483
1083
  const stagingPath = `${this.remoteGitPath}.cloning`;
484
1084
  await fs.rm(stagingPath, { recursive: true, force: true });
485
1085
  try {
486
- await git.clone(await this.buildGitHubUrl(), stagingPath, ['--bare']);
487
- // Before the rename, so the token is gone from the config the moment the
488
- // repo becomes reachable under its real name. Throws (rather than
489
- // swallowing) if the scrub does not take.
490
- await this.scrubPersistedRemote(stagingPath);
1086
+ const githubBranches = await seedFromGitHub(this.transferContext(), stagingPath, this.baseBranch);
1087
+ // The staging path is predictable, and so writable by the Lambda while seeding runs.
1088
+ await assertSharedRepoConfig(stagingPath, 'bare');
1089
+ await check?.(stagingPath, githubBranches);
491
1090
  await this.verifyBaseBranchExists(stagingPath);
1091
+ await this.recordBaseBranchInRemoteHead(stagingPath);
1092
+ await this.applyRemoteGitConfig(stagingPath);
492
1093
  }
493
1094
  catch (err) {
494
- workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
1095
+ if (!(err instanceof RemoteGitKeptError)) {
1096
+ workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
1097
+ }
495
1098
  // Deleting before throwing is what makes this recoverable: the next
496
1099
  // start() sees no remote.git and re-clones, instead of sticking forever
497
1100
  // behind a poisoned bare repo fs.stat alone cannot detect.
498
1101
  await fs.rm(stagingPath, { recursive: true, force: true });
1102
+ if (err instanceof RemoteGitKeptError)
1103
+ throw err;
1104
+ if (err instanceof SharedRepoRefusalError) {
1105
+ // Not the refusal's own advice: the directory it names is gone.
1106
+ throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
1107
+ `remote.git from it` +
1108
+ (err instanceof UntrustedRepoConfigError
1109
+ ? ` (${err.keys.map((k) => k.key).join(', ')})`
1110
+ : '') +
1111
+ `, so it was removed. Find out what wrote there; restarting the worker seeds it again.`);
1112
+ }
499
1113
  throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
500
1114
  }
501
- await fs.rename(stagingPath, this.remoteGitPath);
502
- workerLog('remote.git initialized');
1115
+ return stagingPath;
503
1116
  }
504
1117
  // --- Task-queue cluster (worker/task-runner.ts) ------------------------
505
1118
  //
@@ -524,117 +1137,61 @@ export class CmsWorker {
524
1137
  async updateBranchMetadata(task, result) {
525
1138
  return updateBranchMetadata(this.ctx(), task, result);
526
1139
  }
527
- async pushBranchToGitHub(branch) {
528
- return pushBranchToGitHub(this.ctx(), branch);
1140
+ async pushBranchToGitHub(branch, signal) {
1141
+ return pushBranchToGitHub(this.ctx(), branch, signal);
529
1142
  }
530
1143
  /**
531
- * Resolve which GitHub credential this worker uses, once, and build the
532
- * Octokit client from it.
533
- *
534
- * DEFERRED out of the constructor deliberately, exactly as
535
- * `ensureSettingsBranch()` is and for the reason that method records:
536
- * `resolveWorkerGitHubAuth` throws for a half-configured credential (both
537
- * set, neither set, an unusable mint timeout or refresh interval), and a
538
- * throw during `new CmsWorker(...)` lands BEFORE the only code that writes
539
- * `lastFatalError` — start()'s catch.
540
- *
541
- * Idempotent, and it does NOT replace an `octokit` a test has already
542
- * assigned onto the instance — see the field's comment.
1144
+ * The worker's GitHub gateway, created on first use: inside start()'s try, or wherever a test
1145
+ * entry point first reaches it. Creating it resolves the credential, which throws for a
1146
+ * half-configured one (both set, neither set, an unusable mint timeout or refresh interval) --
1147
+ * never in the constructor, for the reason `ensureSettingsBranch()` gives. Keeps a gateway a
1148
+ * test installed.
543
1149
  */
544
- ensureGitHubAuth() {
545
- if (!this.githubAuth) {
546
- this.githubAuth = resolveWorkerGitHubAuth(this.config);
1150
+ github() {
1151
+ if (this.gateway !== undefined)
1152
+ return this.gateway;
1153
+ const { githubGateway } = this.config;
1154
+ if (githubGateway === undefined) {
1155
+ this.gateway = createLocalGitHubGateway(this.localGitHubGatewayOptions());
1156
+ return this.gateway;
547
1157
  }
548
- if (!this.octokit) {
549
- this.octokit = createCanopyOctokit(this.githubAuth.octokitAuth);
1158
+ const conflicting = [];
1159
+ if (this.config.githubToken)
1160
+ conflicting.push('githubToken');
1161
+ if (this.config.githubAppAuth !== undefined)
1162
+ conflicting.push('githubAppAuth');
1163
+ if (this.config.refreshGitHubToken !== undefined)
1164
+ conflicting.push('refreshGitHubToken');
1165
+ if ((this.config.protectedBranches?.length ?? 0) > 0)
1166
+ conflicting.push('protectedBranches');
1167
+ if (conflicting.length > 0) {
1168
+ throw new Error(`CmsWorker: githubGateway is exclusive with ${conflicting.join(', ')}, which configure the ` +
1169
+ 'in-process gateway; the gateway it is given holds the credential and the protected set');
550
1170
  }
551
- return this.githubAuth;
1171
+ this.gateway = githubGateway;
1172
+ return this.gateway;
552
1173
  }
553
- /**
554
- * The Octokit client, built on first use. Every read goes through here rather
555
- * than touching the field, which the constructor does not populate: a method
556
- * reached without start() would otherwise see `undefined`.
557
- * `rebaseActiveBranches()` is exactly that case — apps/test-app's e2e route
558
- * calls it directly, and its `pollMergeState` dispatch reads `ctx.octokit()`.
559
- */
560
- octokitClient() {
561
- this.ensureGitHubAuth();
562
- return this.octokit;
563
- }
564
- /**
565
- * The single seam through which every git-over-HTTPS credential reaches a git
566
- * command. The only other consumer is Octokit, built from the same resolution
567
- * by `ensureGitHubAuth()` above.
568
- *
569
- * Async because under GitHub App auth `resolveGitToken` mints an installation
570
- * token lasting about an hour. NOTHING may cache what this returns — a URL
571
- * built from an installation token goes stale with it — and resolving per use
572
- * is cheap, since `@octokit/auth-app` answers from its own cache until the
573
- * token is near expiry.
574
- *
575
- * A mint failure propagates AS THROWN, carrying the `.status` that
576
- * `isPermanentTaskFailure` classifies on — see github-auth.ts.
577
- *
578
- * Do NOT add a parallel token accessor alongside it. Every instance-backed
579
- * WorkerContext member stays a function precisely so tests can replace it
580
- * through the instance (worker-context.ts's INVARIANT); a second credential
581
- * path would be one nothing stubs.
582
- */
583
- async buildGitHubUrl() {
584
- const token = await this.ensureGitHubAuth().resolveGitToken();
585
- return `https://x-access-token:${token}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
1174
+ /** What worker/github-transfer.ts needs, for the seeds that run before the loops' context. */
1175
+ transferContext() {
1176
+ return {
1177
+ github: () => this.github(),
1178
+ remoteGitPath: this.remoteGitPath,
1179
+ taskTimeoutMs: this.taskTimeoutMs,
1180
+ };
586
1181
  }
587
- /**
588
- * Prove the GitHub App credential works before anything depends on it.
589
- *
590
- * Without this the first failure comes out of `ensureRemoteGit`'s bare clone
591
- * below, whose catch blames the repository ("may be empty, or the base branch
592
- * may not exist") and sends an operator holding a bad private key looking for
593
- * a problem that does not exist. Called from start()'s try, so the failure is
594
- * also recorded as `lastFatalError` and reaches the admin panel. No-op on the
595
- * token path: a PAT is a literal, so the first real request checks everything
596
- * this could.
597
- *
598
- * FATAL UNLESS THE FAILURE POSITIVELY LOOKS TRANSIENT. Both halves are
599
- * load-bearing. Not always fatal, because the two credential paths must
600
- * degrade alike: on the token path a GitHub 502 during boot is absorbed (a
601
- * warm `remote.git` short-circuits `ensureRemoteGit`, `Promise.allSettled`
602
- * swallows the initial `syncGit`) so the worker starts and its loops retry,
603
- * while rethrowing every error class would make the App path exit and systemd
604
- * crash-loop it until GitHub recovered — each iteration telling the operator
605
- * to check their private key.
606
- *
607
- * But fail CLOSED, via `isTransientAuthFailure` rather than the inverse of
608
- * `isPermanentTaskFailure`: that classifier defaults a status-less error to
609
- * transient, which is right on the task path (bounded by `maxRetries`) and
610
- * wrong here (bounded by nothing). A key that never reaches GitHub at all —
611
- * the wrong type, or too mangled to sign with — fails locally and
612
- * status-lessly, so defaulting to transient would boot a worker with a dead
613
- * credential, record no `lastFatalError`, and show healthy in the admin panel
614
- * while every task and every sync failed.
615
- */
616
- async preflightGitHubAppAuth() {
617
- if (!this.config.githubAppAuth)
618
- return;
619
- try {
620
- await this.ensureGitHubAuth().resolveGitToken();
621
- }
622
- catch (err) {
623
- // [REDACT] Both messages below reach worker-status.json and the browser.
624
- const detail = redactCredentials(getErrorMessage(err));
625
- if (isTransientAuthFailure(err)) {
626
- workerLogWarn(`Could not verify GitHub App authentication at startup: ${detail}. ` +
627
- 'Continuing — this looks transient, and the credential is minted again on first use.');
628
- return;
629
- }
630
- // Re-thrown WITH context, unlike buildGitHubUrl() above, which must
631
- // preserve the error identity for task classification. Nothing classifies
632
- // a startup failure -- start()'s catch records the message and the
633
- // process exits -- so the operator-facing wording wins here.
634
- throw new Error(`GitHub App authentication failed: ${detail}. ` +
635
- 'Check the app id, the installation id, and that the private key belongs to that app.');
636
- }
637
- workerLog('GitHub App authentication verified');
1182
+ /** What the in-process gateway is built from; test-utils/worker-gateway.ts builds from it too. */
1183
+ localGitHubGatewayOptions() {
1184
+ return {
1185
+ githubOwner: this.config.githubOwner,
1186
+ githubRepo: this.config.githubRepo,
1187
+ auth: resolveWorkerGitHubAuth(this.config),
1188
+ githubApp: Boolean(this.config.githubAppAuth),
1189
+ stateDirectory: this.stateDirectory,
1190
+ workspacePath: this.config.workspacePath,
1191
+ timeoutMs: this.taskTimeoutMs,
1192
+ baseBranch: this.config.baseBranch,
1193
+ protectedBranches: this.config.protectedBranches,
1194
+ };
638
1195
  }
639
1196
  /**
640
1197
  * The workspace directory for a branch named by its GIT REF name -- the form
@@ -699,78 +1256,8 @@ export class CmsWorker {
699
1256
  async syncGit() {
700
1257
  return syncGit(this.ctx());
701
1258
  }
702
- /**
703
- * `syncGit`, plus "the credential may have rotated" on the way out.
704
- *
705
- * One of `refreshGitHubCredential`'s two call sites, and the one that works
706
- * when nobody is publishing: it fetches from GitHub every `gitSyncInterval`
707
- * whether or not anyone is editing, so a credential that has stopped working
708
- * surfaces here even with no push queued for days.
709
- *
710
- * The SYNC failure is what propagates to `scheduleLoop`'s catch;
711
- * `refreshGitHubCredential` never throws, so nothing it does can replace it.
712
- */
713
- async syncGitWithCredentialRefresh() {
714
- try {
715
- await this.syncGit();
716
- }
717
- catch (err) {
718
- await this.refreshGitHubCredential();
719
- throw err;
720
- }
721
- }
722
- /**
723
- * Re-read the GitHub credential, because an operation using it just failed.
724
- *
725
- * **Two call sites, each covering what the other cannot.** The git-sync loop
726
- * (`syncGitWithCredentialRefresh`) notices a dead credential when nobody is
727
- * publishing. `processTaskQueue`'s per-task catch is what saves a publish: a
728
- * push task spends its retry budget on a 5s/10s/20s backoff, well inside one
729
- * 5-minute sync interval, so with the sync loop as the only trigger a publish
730
- * meeting a rotated token fails permanently while the working one is already
731
- * in the secret store. Every consumer reaches the credential through
732
- * `ensureGitHubAuth()`, which reads it per use, so a refresh from either site
733
- * repairs all of them for their NEXT use — but not an attempt already failed.
734
- *
735
- * NOT gated on the error looking auth-shaped, at either site: a `git
736
- * fetch`/`push` rejected for a dead token throws a plain simple-git error
737
- * (exit 128, no HTTP `.status`) that `isPermanentTaskFailure` reads as
738
- * transient, so a gate keyed on it would never fire. Two floors bound the
739
- * cost instead — core's `refreshGitHubTokenMinIntervalMs` (default 60s,
740
- * enforced by `refreshCredential` in github-auth.ts) and whatever floor the
741
- * provider keeps (the AWS one reads at most once per five minutes) — and both
742
- * call sites share both, so a read issued by one throttles the other. On the
743
- * GitHub App path the refresh is a no-op.
744
- *
745
- * **Never throws**: both callers are already reporting the failure that must
746
- * reach the log. **Bounded by `taskTimeoutMs`**, because the task loop awaits
747
- * it and a read that never settled would stop every publish queued behind it
748
- * (an adopter's provider may have no bound at all; the AWS one can take 87s
749
- * for one `getSecret`). A losing read is not cancelled and may land later, but
750
- * `refreshCredential` discards a result older than one already applied.
751
- */
752
- async refreshGitHubCredential() {
753
- let timer;
754
- const timedOut = new Promise((_, reject) => {
755
- timer = setTimeout(() => reject(new Error(`the re-read did not settle within ${this.taskTimeoutMs}ms`)), this.taskTimeoutMs);
756
- });
757
- try {
758
- // `ensureGitHubAuth()` inside the try: it throws for a half-configured
759
- // credential, and that has to be logged like any other refresh failure.
760
- await Promise.race([this.ensureGitHubAuth().refreshCredential(), timedOut]);
761
- }
762
- catch (err) {
763
- // [REDACT] The message can name the secret and, on a malformed-secret
764
- // path, quote what was read. Console only, but the rule is uniform --
765
- // see redactCredentials in utils/error.ts.
766
- workerLogError('Failed to re-read the GitHub credential after a failure:', redactCredentials(getErrorMessage(err)));
767
- }
768
- finally {
769
- clearTimeout(timer);
770
- }
771
- }
772
- async pushSettingsBranches(git, trackedNames) {
773
- return pushSettingsBranches(this.ctx(), git, trackedNames);
1259
+ async pushSettingsBranches(git, trackedTips) {
1260
+ return pushSettingsBranches(this.ctx(), git, trackedTips);
774
1261
  }
775
1262
  async refreshBaseBranchWorkspace() {
776
1263
  return refreshBaseBranchWorkspace(this.ctx());