canopycms 0.0.61-int.75 → 0.0.62

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 (369) hide show
  1. package/dist/ai/handler.js +1 -1
  2. package/dist/ai/handler.js.map +1 -1
  3. package/dist/ai/json-to-markdown.js +1 -2
  4. package/dist/ai/json-to-markdown.js.map +1 -1
  5. package/dist/api/__test__/mock-client.d.ts +5 -1
  6. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  7. package/dist/api/__test__/mock-client.js +9 -2
  8. package/dist/api/__test__/mock-client.js.map +1 -1
  9. package/dist/api/admin-branch-health.d.ts +36 -1
  10. package/dist/api/admin-branch-health.d.ts.map +1 -1
  11. package/dist/api/admin-branch-health.js +176 -2
  12. package/dist/api/admin-branch-health.js.map +1 -1
  13. package/dist/api/admin.d.ts +8 -1
  14. package/dist/api/admin.d.ts.map +1 -1
  15. package/dist/api/admin.js.map +1 -1
  16. package/dist/api/assets.d.ts.map +1 -1
  17. package/dist/api/assets.js +36 -4
  18. package/dist/api/assets.js.map +1 -1
  19. package/dist/api/branch-status.d.ts.map +1 -1
  20. package/dist/api/branch-status.js +56 -0
  21. package/dist/api/branch-status.js.map +1 -1
  22. package/dist/api/branch-withdraw.d.ts.map +1 -1
  23. package/dist/api/branch-withdraw.js +14 -3
  24. package/dist/api/branch-withdraw.js.map +1 -1
  25. package/dist/api/branch.d.ts +31 -2
  26. package/dist/api/branch.d.ts.map +1 -1
  27. package/dist/api/branch.js +268 -15
  28. package/dist/api/branch.js.map +1 -1
  29. package/dist/api/client.d.ts +5 -1
  30. package/dist/api/client.d.ts.map +1 -1
  31. package/dist/api/client.js +6 -0
  32. package/dist/api/client.js.map +1 -1
  33. package/dist/api/comments.d.ts +2 -2
  34. package/dist/api/content.d.ts +11 -5
  35. package/dist/api/content.d.ts.map +1 -1
  36. package/dist/api/content.js +91 -10
  37. package/dist/api/content.js.map +1 -1
  38. package/dist/api/entries.d.ts +10 -0
  39. package/dist/api/entries.d.ts.map +1 -1
  40. package/dist/api/entries.js +51 -24
  41. package/dist/api/entries.js.map +1 -1
  42. package/dist/api/guards.d.ts.map +1 -1
  43. package/dist/api/guards.js +12 -5
  44. package/dist/api/guards.js.map +1 -1
  45. package/dist/api/permissions.d.ts +2 -2
  46. package/dist/api/permissions.d.ts.map +1 -1
  47. package/dist/api/permissions.js +51 -3
  48. package/dist/api/permissions.js.map +1 -1
  49. package/dist/api/reference-options.d.ts.map +1 -1
  50. package/dist/api/reference-options.js +10 -17
  51. package/dist/api/reference-options.js.map +1 -1
  52. package/dist/api/resolve-references.d.ts.map +1 -1
  53. package/dist/api/resolve-references.js +3 -1
  54. package/dist/api/resolve-references.js.map +1 -1
  55. package/dist/api/schema.d.ts +4 -4
  56. package/dist/api/schema.d.ts.map +1 -1
  57. package/dist/api/schema.js +16 -15
  58. package/dist/api/schema.js.map +1 -1
  59. package/dist/api/settings-helpers.d.ts.map +1 -1
  60. package/dist/api/settings-helpers.js +6 -5
  61. package/dist/api/settings-helpers.js.map +1 -1
  62. package/dist/assets/pipeline.d.ts +11 -2
  63. package/dist/assets/pipeline.d.ts.map +1 -1
  64. package/dist/assets/pipeline.js +114 -2
  65. package/dist/assets/pipeline.js.map +1 -1
  66. package/dist/assets/transform-directives.d.ts +18 -0
  67. package/dist/assets/transform-directives.d.ts.map +1 -1
  68. package/dist/assets/transform-directives.js +18 -0
  69. package/dist/assets/transform-directives.js.map +1 -1
  70. package/dist/assets/transform.d.ts.map +1 -1
  71. package/dist/assets/transform.js +20 -16
  72. package/dist/assets/transform.js.map +1 -1
  73. package/dist/auth/types.d.ts +29 -0
  74. package/dist/auth/types.d.ts.map +1 -1
  75. package/dist/authorization/index.d.ts +1 -1
  76. package/dist/authorization/index.d.ts.map +1 -1
  77. package/dist/authorization/index.js +1 -1
  78. package/dist/authorization/index.js.map +1 -1
  79. package/dist/authorization/protected-branch.d.ts +75 -0
  80. package/dist/authorization/protected-branch.d.ts.map +1 -1
  81. package/dist/authorization/protected-branch.js +39 -0
  82. package/dist/authorization/protected-branch.js.map +1 -1
  83. package/dist/branch-health.d.ts +13 -0
  84. package/dist/branch-health.d.ts.map +1 -1
  85. package/dist/branch-health.js +23 -0
  86. package/dist/branch-health.js.map +1 -1
  87. package/dist/branch-registry.d.ts.map +1 -1
  88. package/dist/branch-registry.js +5 -1
  89. package/dist/branch-registry.js.map +1 -1
  90. package/dist/branch-schema-cache.d.ts.map +1 -1
  91. package/dist/branch-schema-cache.js +9 -0
  92. package/dist/branch-schema-cache.js.map +1 -1
  93. package/dist/build/generate-ai-content.js +1 -1
  94. package/dist/build/generate-ai-content.js.map +1 -1
  95. package/dist/cli/cli.js +2215 -1280
  96. package/dist/cli/generate-ai-content.js +1158 -585
  97. package/dist/cli/init.d.ts +8 -2
  98. package/dist/cli/init.d.ts.map +1 -1
  99. package/dist/cli/init.js +459 -152
  100. package/dist/cli/init.js.map +1 -1
  101. package/dist/cli/project-detect.d.ts +89 -0
  102. package/dist/cli/project-detect.d.ts.map +1 -0
  103. package/dist/cli/project-detect.js +240 -0
  104. package/dist/cli/project-detect.js.map +1 -0
  105. package/dist/cli/template-files/Dockerfile.cms.template +30 -7
  106. package/dist/cli/template-files/canopycms.config.ts.template +7 -0
  107. package/dist/cli/template-files/cdk-app.ts.template +82 -0
  108. package/dist/cli/template-files/cdk.json.template +9 -0
  109. package/dist/cli/template-files/cms-stack.ts.template +130 -0
  110. package/dist/cli/template-files/deploy-cms.yml.template +125 -13
  111. package/dist/cli/template-files/dockerignore.template +2 -1
  112. package/dist/cli/templates.d.ts +26 -2
  113. package/dist/cli/templates.d.ts.map +1 -1
  114. package/dist/cli/templates.js +36 -4
  115. package/dist/cli/templates.js.map +1 -1
  116. package/dist/client.d.ts +1 -0
  117. package/dist/client.d.ts.map +1 -1
  118. package/dist/client.js.map +1 -1
  119. package/dist/config/schemas/config.d.ts +2 -3
  120. package/dist/config/schemas/config.d.ts.map +1 -1
  121. package/dist/config/schemas/config.js +21 -1
  122. package/dist/config/schemas/config.js.map +1 -1
  123. package/dist/config/schemas/field.d.ts +15 -15
  124. package/dist/config/types.d.ts +2 -2
  125. package/dist/config/types.d.ts.map +1 -1
  126. package/dist/config/types.js +0 -1
  127. package/dist/config/types.js.map +1 -1
  128. package/dist/config/validation.d.ts +18 -0
  129. package/dist/config/validation.d.ts.map +1 -1
  130. package/dist/config/validation.js +36 -12
  131. package/dist/config/validation.js.map +1 -1
  132. package/dist/content-id-index.d.ts +97 -5
  133. package/dist/content-id-index.d.ts.map +1 -1
  134. package/dist/content-id-index.js +170 -18
  135. package/dist/content-id-index.js.map +1 -1
  136. package/dist/content-reader.js +1 -1
  137. package/dist/content-reader.js.map +1 -1
  138. package/dist/content-store.d.ts +92 -7
  139. package/dist/content-store.d.ts.map +1 -1
  140. package/dist/content-store.js +445 -259
  141. package/dist/content-store.js.map +1 -1
  142. package/dist/dev-content-watcher.d.ts.map +1 -1
  143. package/dist/dev-content-watcher.js +4 -1
  144. package/dist/dev-content-watcher.js.map +1 -1
  145. package/dist/editor/BranchManager.d.ts +15 -0
  146. package/dist/editor/BranchManager.d.ts.map +1 -1
  147. package/dist/editor/BranchManager.js +61 -15
  148. package/dist/editor/BranchManager.js.map +1 -1
  149. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  150. package/dist/editor/CanopyEditor.js +3 -3
  151. package/dist/editor/CanopyEditor.js.map +1 -1
  152. package/dist/editor/CanopyEditorPage.d.ts +9 -1
  153. package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
  154. package/dist/editor/CanopyEditorPage.js +9 -2
  155. package/dist/editor/CanopyEditorPage.js.map +1 -1
  156. package/dist/editor/Editor.d.ts +9 -1
  157. package/dist/editor/Editor.d.ts.map +1 -1
  158. package/dist/editor/Editor.js +156 -60
  159. package/dist/editor/Editor.js.map +1 -1
  160. package/dist/editor/FormRenderer.d.ts.map +1 -1
  161. package/dist/editor/FormRenderer.js +12 -0
  162. package/dist/editor/FormRenderer.js.map +1 -1
  163. package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -1
  164. package/dist/editor/admin/SystemHealthPanel.js +14 -9
  165. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  166. package/dist/editor/components/EditorHeader.d.ts +6 -0
  167. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  168. package/dist/editor/components/EditorHeader.js +76 -23
  169. package/dist/editor/components/EditorHeader.js.map +1 -1
  170. package/dist/editor/components/EntryCreateModal.d.ts +9 -1
  171. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  172. package/dist/editor/components/EntryCreateModal.js +25 -7
  173. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  174. package/dist/editor/context/SWRProvider.d.ts +31 -0
  175. package/dist/editor/context/SWRProvider.d.ts.map +1 -0
  176. package/dist/editor/context/SWRProvider.js +12 -0
  177. package/dist/editor/context/SWRProvider.js.map +1 -0
  178. package/dist/editor/context/index.d.ts +3 -0
  179. package/dist/editor/context/index.d.ts.map +1 -1
  180. package/dist/editor/context/index.js +3 -0
  181. package/dist/editor/context/index.js.map +1 -1
  182. package/dist/editor/editor-utils.d.ts.map +1 -1
  183. package/dist/editor/editor-utils.js +8 -2
  184. package/dist/editor/editor-utils.js.map +1 -1
  185. package/dist/editor/fields/DateTimeField.d.ts +46 -0
  186. package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
  187. package/dist/editor/fields/DateTimeField.js +66 -0
  188. package/dist/editor/fields/DateTimeField.js.map +1 -0
  189. package/dist/editor/fields/ImageField.d.ts.map +1 -1
  190. package/dist/editor/fields/ImageField.js +27 -3
  191. package/dist/editor/fields/ImageField.js.map +1 -1
  192. package/dist/editor/fields/NumberField.d.ts +34 -0
  193. package/dist/editor/fields/NumberField.d.ts.map +1 -0
  194. package/dist/editor/fields/NumberField.js +53 -0
  195. package/dist/editor/fields/NumberField.js.map +1 -0
  196. package/dist/editor/fields/NumberListField.d.ts +25 -0
  197. package/dist/editor/fields/NumberListField.d.ts.map +1 -0
  198. package/dist/editor/fields/NumberListField.js +53 -0
  199. package/dist/editor/fields/NumberListField.js.map +1 -0
  200. package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
  201. package/dist/editor/fields/ReferenceField.js +52 -31
  202. package/dist/editor/fields/ReferenceField.js.map +1 -1
  203. package/dist/editor/hooks/__test__/test-utils.d.ts +38 -3
  204. package/dist/editor/hooks/__test__/test-utils.d.ts.map +1 -1
  205. package/dist/editor/hooks/__test__/test-utils.js +50 -4
  206. package/dist/editor/hooks/__test__/test-utils.js.map +1 -1
  207. package/dist/editor/hooks/index.d.ts +4 -1
  208. package/dist/editor/hooks/index.d.ts.map +1 -1
  209. package/dist/editor/hooks/index.js +4 -1
  210. package/dist/editor/hooks/index.js.map +1 -1
  211. package/dist/editor/hooks/useBranchManager.d.ts +21 -1
  212. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  213. package/dist/editor/hooks/useBranchManager.js +183 -58
  214. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  215. package/dist/editor/hooks/useBranchesData.d.ts +57 -0
  216. package/dist/editor/hooks/useBranchesData.d.ts.map +1 -0
  217. package/dist/editor/hooks/useBranchesData.js +69 -0
  218. package/dist/editor/hooks/useBranchesData.js.map +1 -0
  219. package/dist/editor/hooks/useCommentSystem.d.ts.map +1 -1
  220. package/dist/editor/hooks/useCommentSystem.js +24 -22
  221. package/dist/editor/hooks/useCommentSystem.js.map +1 -1
  222. package/dist/editor/hooks/useCommentsData.d.ts +29 -0
  223. package/dist/editor/hooks/useCommentsData.d.ts.map +1 -0
  224. package/dist/editor/hooks/useCommentsData.js +40 -0
  225. package/dist/editor/hooks/useCommentsData.js.map +1 -0
  226. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  227. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  228. package/dist/editor/hooks/useDraftManager.js +290 -36
  229. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  230. package/dist/editor/hooks/useEntriesData.d.ts +35 -0
  231. package/dist/editor/hooks/useEntriesData.d.ts.map +1 -0
  232. package/dist/editor/hooks/useEntriesData.js +96 -0
  233. package/dist/editor/hooks/useEntriesData.js.map +1 -0
  234. package/dist/editor/hooks/useEntryManager.d.ts +29 -15
  235. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  236. package/dist/editor/hooks/useEntryManager.js +284 -119
  237. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  238. package/dist/editor/hooks/usePermissionManager.d.ts +2 -2
  239. package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
  240. package/dist/editor/hooks/usePermissionManager.js +11 -9
  241. package/dist/editor/hooks/usePermissionManager.js.map +1 -1
  242. package/dist/editor/hooks/useReferenceResolution.d.ts.map +1 -1
  243. package/dist/editor/hooks/useReferenceResolution.js +24 -1
  244. package/dist/editor/hooks/useReferenceResolution.js.map +1 -1
  245. package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -1
  246. package/dist/editor/media/MediaLibraryBody.js +12 -2
  247. package/dist/editor/media/MediaLibraryBody.js.map +1 -1
  248. package/dist/editor/permission-manager/GroupSelector.d.ts.map +1 -1
  249. package/dist/editor/permission-manager/GroupSelector.js +7 -2
  250. package/dist/editor/permission-manager/GroupSelector.js.map +1 -1
  251. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +3 -3
  252. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts.map +1 -1
  253. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +1 -0
  254. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js.map +1 -1
  255. package/dist/editor/permission-manager/types.d.ts +6 -4
  256. package/dist/editor/permission-manager/types.d.ts.map +1 -1
  257. package/dist/editor/test-setup.js +8 -0
  258. package/dist/editor/test-setup.js.map +1 -1
  259. package/dist/editor/theme.d.ts +8 -8
  260. package/dist/git-manager.d.ts +175 -14
  261. package/dist/git-manager.d.ts.map +1 -1
  262. package/dist/git-manager.js +354 -39
  263. package/dist/git-manager.js.map +1 -1
  264. package/dist/github-service.d.ts.map +1 -1
  265. package/dist/github-service.js +13 -7
  266. package/dist/github-service.js.map +1 -1
  267. package/dist/http/handler.d.ts.map +1 -1
  268. package/dist/http/handler.js +78 -29
  269. package/dist/http/handler.js.map +1 -1
  270. package/dist/http/router.d.ts.map +1 -1
  271. package/dist/http/router.js +40 -3
  272. package/dist/http/router.js.map +1 -1
  273. package/dist/operating-mode/client-unsafe-strategy.d.ts.map +1 -1
  274. package/dist/operating-mode/client-unsafe-strategy.js +7 -8
  275. package/dist/operating-mode/client-unsafe-strategy.js.map +1 -1
  276. package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
  277. package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
  278. package/dist/operating-mode/deployment-name-fixtures.js +55 -0
  279. package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
  280. package/dist/operating-mode/deployment-name.d.ts +37 -0
  281. package/dist/operating-mode/deployment-name.d.ts.map +1 -0
  282. package/dist/operating-mode/deployment-name.js +86 -0
  283. package/dist/operating-mode/deployment-name.js.map +1 -0
  284. package/dist/operating-mode/index.d.ts +6 -1
  285. package/dist/operating-mode/index.d.ts.map +1 -1
  286. package/dist/operating-mode/index.js +10 -1
  287. package/dist/operating-mode/index.js.map +1 -1
  288. package/dist/operating-mode/mode-env.d.ts +58 -0
  289. package/dist/operating-mode/mode-env.d.ts.map +1 -0
  290. package/dist/operating-mode/mode-env.js +97 -0
  291. package/dist/operating-mode/mode-env.js.map +1 -0
  292. package/dist/operating-mode/types.d.ts +13 -3
  293. package/dist/operating-mode/types.d.ts.map +1 -1
  294. package/dist/paths/branch-name.d.ts +38 -0
  295. package/dist/paths/branch-name.d.ts.map +1 -1
  296. package/dist/paths/branch-name.js +46 -0
  297. package/dist/paths/branch-name.js.map +1 -1
  298. package/dist/paths/index.d.ts +1 -1
  299. package/dist/paths/index.d.ts.map +1 -1
  300. package/dist/paths/index.js +1 -1
  301. package/dist/paths/index.js.map +1 -1
  302. package/dist/reference-resolver.d.ts +6 -2
  303. package/dist/reference-resolver.d.ts.map +1 -1
  304. package/dist/reference-resolver.js +8 -1
  305. package/dist/reference-resolver.js.map +1 -1
  306. package/dist/resolve-canopy-user.d.ts +34 -0
  307. package/dist/resolve-canopy-user.d.ts.map +1 -0
  308. package/dist/resolve-canopy-user.js +55 -0
  309. package/dist/resolve-canopy-user.js.map +1 -0
  310. package/dist/schema/meta-loader.d.ts.map +1 -1
  311. package/dist/schema/meta-loader.js +10 -2
  312. package/dist/schema/meta-loader.js.map +1 -1
  313. package/dist/schema/schema-store.d.ts +64 -4
  314. package/dist/schema/schema-store.d.ts.map +1 -1
  315. package/dist/schema/schema-store.js +113 -30
  316. package/dist/schema/schema-store.js.map +1 -1
  317. package/dist/server.d.ts +9 -0
  318. package/dist/server.d.ts.map +1 -1
  319. package/dist/server.js +9 -0
  320. package/dist/server.js.map +1 -1
  321. package/dist/services.d.ts.map +1 -1
  322. package/dist/services.js +40 -6
  323. package/dist/services.js.map +1 -1
  324. package/dist/settings-workspace.d.ts +27 -2
  325. package/dist/settings-workspace.d.ts.map +1 -1
  326. package/dist/settings-workspace.js +142 -58
  327. package/dist/settings-workspace.js.map +1 -1
  328. package/dist/types.d.ts +73 -2
  329. package/dist/types.d.ts.map +1 -1
  330. package/dist/utils/content-write-lock.d.ts +127 -0
  331. package/dist/utils/content-write-lock.d.ts.map +1 -0
  332. package/dist/utils/content-write-lock.js +170 -0
  333. package/dist/utils/content-write-lock.js.map +1 -0
  334. package/dist/utils/debug.d.ts +11 -0
  335. package/dist/utils/debug.d.ts.map +1 -1
  336. package/dist/utils/debug.js +12 -1
  337. package/dist/utils/debug.js.map +1 -1
  338. package/dist/utils/error.d.ts +1 -1
  339. package/dist/utils/error.js +1 -1
  340. package/dist/utils/git.d.ts +54 -0
  341. package/dist/utils/git.d.ts.map +1 -1
  342. package/dist/utils/git.js +91 -0
  343. package/dist/utils/git.js.map +1 -1
  344. package/dist/utils/logger.d.ts +71 -0
  345. package/dist/utils/logger.d.ts.map +1 -0
  346. package/dist/utils/logger.js +85 -0
  347. package/dist/utils/logger.js.map +1 -0
  348. package/dist/utils/sanitize-href.d.ts +28 -3
  349. package/dist/utils/sanitize-href.d.ts.map +1 -1
  350. package/dist/utils/sanitize-href.js +64 -6
  351. package/dist/utils/sanitize-href.js.map +1 -1
  352. package/dist/validation/entry-link-validator.d.ts +1 -1
  353. package/dist/validation/entry-link-validator.js +3 -3
  354. package/dist/validation/entry-link-validator.js.map +1 -1
  355. package/dist/validation/entry-type-reference-validator.d.ts +33 -0
  356. package/dist/validation/entry-type-reference-validator.d.ts.map +1 -0
  357. package/dist/validation/entry-type-reference-validator.js +110 -0
  358. package/dist/validation/entry-type-reference-validator.js.map +1 -0
  359. package/dist/validation/entry-validator.js +1 -1
  360. package/dist/validation/entry-validator.js.map +1 -1
  361. package/dist/worker/cms-worker.d.ts +245 -3
  362. package/dist/worker/cms-worker.d.ts.map +1 -1
  363. package/dist/worker/cms-worker.js +1084 -253
  364. package/dist/worker/cms-worker.js.map +1 -1
  365. package/dist/worker/log.d.ts +61 -0
  366. package/dist/worker/log.d.ts.map +1 -0
  367. package/dist/worker/log.js +74 -0
  368. package/dist/worker/log.js.map +1 -0
  369. package/package.json +4 -2
@@ -2,66 +2,125 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { GitManager } from './git-manager.js';
4
4
  import { createDebugLogger } from './utils/debug.js';
5
- import { isFileExistsError } from './utils/error.js';
5
+ import { getErrorMessage } from './utils/error.js';
6
+ import { acquireProvisioningLock } from './utils/provisioning-lock.js';
7
+ import { RESERVED_SETTINGS_BRANCH_PREFIX } from './paths/index.js';
6
8
  const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
7
9
  // In-memory lock to prevent concurrent workspace initialization within the same process.
8
10
  // Settings only need one lock (not per-branch like content branches).
9
11
  let settingsInitLock = null;
10
- // Stale lock threshold — init should complete well within this window
11
- const LOCK_STALE_MS = 30_000;
12
+ const SETTINGS_INIT_LOCK_DIR = '.settings-init';
13
+ const SETTINGS_INIT_LOCK_NAME = 'lock';
12
14
  /**
13
- * Acquire a file-based lock for cross-process synchronization.
14
- * Uses O_CREAT|O_EXCL (wx flag) for atomic file creation.
15
- * Stale locks (older than LOCK_STALE_MS) are cleaned up automatically.
15
+ * Directory the cross-process init lock is anchored on — a dedicated sibling of
16
+ * the settings root, and NOT the settings root itself.
16
17
  *
17
- * Returns true if lock was acquired, false if another process holds it.
18
+ * Two properties this name has to satisfy:
19
+ *
20
+ * 1. **It must not pre-create the settings root.** `acquireProvisioningLock`
21
+ * mkdir's its target, and `GitManager.initializeWorkspace` clones INTO the
22
+ * settings root — `git clone` refuses a destination that already has content
23
+ * in it. So the lock lives beside the settings root, never inside it.
24
+ * 2. **It must not alias any other proper-lockfile target.** proper-lockfile
25
+ * keys its module-level bookkeeping (refresh timer + release function) by the
26
+ * TARGET path passed to `lock()`, not by `lockfilePath`, so two live locks in
27
+ * one process that share a target clobber each other's registry entry (see
28
+ * `.claude/future-tasks/proper-lockfile-hazards.md`). This target is a
29
+ * dedicated dot-directory, so it collides with none of the existing targets:
30
+ * the content-branches root and the bare remote's parent (provisioning locks
31
+ * — note the latter IS `path.dirname(settingsRoot)`, which is precisely why
32
+ * this cannot be anchored there: settings init calls into
33
+ * `ensureLocalSimulatedRemote` while holding this lock), `<branchRoot>/.canopy-meta`
34
+ * (content-write lock), `<workspaceRoot>/.tasks` (worker task lock), and the
35
+ * individual JSON file paths used by `withOccFileLock`.
18
36
  */
19
- async function acquireFileLock(lockPath) {
20
- const lockContent = JSON.stringify({
21
- pid: process.pid,
22
- timestamp: new Date().toISOString(),
23
- });
24
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
25
- try {
26
- const handle = await fs.open(lockPath, 'wx');
27
- await handle.writeFile(lockContent, 'utf-8');
28
- await handle.close();
29
- return true;
30
- }
31
- catch (err) {
32
- if (!isFileExistsError(err))
33
- throw err;
34
- }
35
- // Lock file exists — check if stale
36
- try {
37
- const stat = await fs.stat(lockPath);
38
- const ageMs = Date.now() - stat.mtimeMs;
39
- if (ageMs < LOCK_STALE_MS) {
40
- // Lock is fresh — another process is initializing
41
- return false;
37
+ export function settingsInitLockTarget(settingsRoot) {
38
+ return path.join(path.dirname(path.resolve(settingsRoot)), SETTINGS_INIT_LOCK_DIR);
39
+ }
40
+ /**
41
+ * Whether a settings workspace actually holds settings data yet. Used by the
42
+ * rename guard to tell an already-populated workspace (refuse) apart from a
43
+ * clone that was interrupted before its orphan branch was ever created
44
+ * (harmless — let init finish). File names come from the operating-mode
45
+ * strategy so this stays in step with whatever the mode calls them.
46
+ */
47
+ async function settingsFilesPresent(settingsRoot) {
48
+ const names = ['permissions.json', 'groups.json'];
49
+ for (const name of names) {
50
+ try {
51
+ await fs.access(path.join(settingsRoot, name));
52
+ return true;
53
+ }
54
+ catch {
55
+ // Not present — keep checking the rest.
42
56
  }
43
- // Stale lock — another process likely crashed during init
44
- log.debug('workspace', 'Removing stale settings init lock', { ageMs });
45
- await fs.unlink(lockPath).catch(() => { });
46
- }
47
- catch {
48
- // Lock file vanished between check and stat — try again
49
57
  }
50
- // Retry lock acquisition after stale cleanup
58
+ return false;
59
+ }
60
+ /**
61
+ * Rename guard (settings-branch protection). Refuses to boot when an
62
+ * ALREADY-POPULATED settings workspace would be re-orphaned under a different
63
+ * branch name.
64
+ *
65
+ * Trace of what happens without it: GitManager.initializeWorkspace sees an
66
+ * existing .git (no clone happens) and goes straight to
67
+ * createOrphanSettingsBranch(branchName). If that name isn't already known, git
68
+ * runs `checkout --orphan <name>` + `rm -rf .` + an empty `--allow-empty`
69
+ * commit — orphan branches share no history, so this isn't a "migration", it
70
+ * PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
71
+ * That almost always means deploymentName / settingsBranch /
72
+ * CANOPYCMS_DEPLOYMENT_NAME changed on a live deployment (see
73
+ * resolveDeploymentName in operating-mode/deployment-name.ts).
74
+ *
75
+ * What this deliberately does NOT refuse, and why it matters:
76
+ * initializeWorkspace clones at the BASE branch and only creates the orphan
77
+ * branch several steps later (addConfig, resolveRemoteUrl, ensureRemote come in
78
+ * between). If that first init is interrupted — a Lambda timeout on a slow EFS
79
+ * clone, an OOM, a spot interruption — it leaves a perfectly valid repo sitting
80
+ * on the base branch with NO settings files in it. Refusing on "branch differs"
81
+ * alone would then brick the deployment permanently on every subsequent boot, to
82
+ * protect data that was never written. So the refusal additionally requires
83
+ * evidence that this really is a populated settings workspace: either it is
84
+ * checked out on some OTHER settings branch, or the settings files are actually
85
+ * on disk.
86
+ *
87
+ * This function is NEVER gated on holding the init lock — see its two call
88
+ * sites in ensureGitWorkspace and the comment there.
89
+ */
90
+ async function assertSettingsWorkspaceIdentity(options) {
91
+ const repoExists = await GitManager.repoExistsAt(options.settingsRoot);
92
+ if (!repoExists)
93
+ return;
94
+ let currentBranch;
95
+ let readError;
51
96
  try {
52
- const handle = await fs.open(lockPath, 'wx');
53
- await handle.writeFile(lockContent, 'utf-8');
54
- await handle.close();
55
- return true;
97
+ const existing = new GitManager({ repoPath: options.settingsRoot });
98
+ currentBranch = (await existing.status()).current ?? undefined;
56
99
  }
57
- catch (retryErr) {
58
- if (isFileExistsError(retryErr))
59
- return false;
60
- throw retryErr;
100
+ catch (err) {
101
+ currentBranch = undefined;
102
+ readError = getErrorMessage(err);
103
+ }
104
+ const onDifferentBranch = currentBranch !== options.branchName;
105
+ const looksLikeSettingsBranch = currentBranch !== undefined && currentBranch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
106
+ const hasSettingsData = await settingsFilesPresent(options.settingsRoot);
107
+ if (onDifferentBranch && (looksLikeSettingsBranch || hasSettingsData)) {
108
+ throw new Error(`CanopyCMS: refusing to initialize settings workspace at ${options.settingsRoot}. ` +
109
+ (currentBranch
110
+ ? `It is currently on branch '${currentBranch}', but this deployment resolved ` +
111
+ `settings branch '${options.branchName}'.`
112
+ : `Its current branch could not be read (${readError ?? 'unknown error'}), but it ` +
113
+ `holds settings files, and this deployment resolved settings branch ` +
114
+ `'${options.branchName}'.`) +
115
+ ` Proceeding would run \`git checkout --orphan\` + \`rm -rf .\` on this workspace, ` +
116
+ `which PERMANENTLY WIPES permissions.json/groups.json (orphan branches share no ` +
117
+ `history — there is nothing to migrate from). This almost always means ` +
118
+ `deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
119
+ `deployment that already has a populated settings workspace. To resolve: restore ` +
120
+ `the previous value so this resolves back to ` +
121
+ `'${currentBranch ?? options.branchName}', or, if starting fresh is genuinely ` +
122
+ `intended, move ${options.settingsRoot} aside manually first.`);
61
123
  }
62
- }
63
- async function releaseFileLock(lockPath) {
64
- await fs.unlink(lockPath).catch(() => { });
65
124
  }
66
125
  /**
67
126
  * Manages settings filesystem workspace and git operations.
@@ -74,9 +133,10 @@ async function releaseFileLock(lockPath) {
74
133
  * - Interact with the branch registry
75
134
  * - Check for special cases (settings are always settings)
76
135
  *
77
- * Uses two layers of locking:
136
+ * Uses two layers of locking, mirroring BranchWorkspaceManager:
78
137
  * - In-memory Promise lock for within-process serialization (Lambda request lifecycle)
79
- * - File-based lock for cross-process synchronization (multiple Lambda instances on EFS)
138
+ * - `acquireProvisioningLock` (proper-lockfile) for cross-process/cross-host
139
+ * synchronization of the init itself (multiple Lambda containers on EFS)
80
140
  */
81
141
  export class SettingsWorkspaceManager {
82
142
  constructor(config) {
@@ -96,13 +156,34 @@ export class SettingsWorkspaceManager {
96
156
  branchName: options.branchName,
97
157
  mode: options.mode,
98
158
  });
99
- // Layer 2: File-based lock (prevents concurrent init across processes)
100
- // Lock file is placed OUTSIDE the settings root (as a sibling) so that
101
- // acquireFileLock's mkdir does not pre-create the settings directory,
102
- // which would cause git clone to fail ("already exists and is not empty").
103
- const lockPath = path.join(path.dirname(options.settingsRoot), '.settings-init.lock');
104
- const acquired = await acquireFileLock(lockPath);
159
+ // Rename guard, run LOCK-FREE and unconditionally, before any waiting.
160
+ // A deployment whose settings-branch name no longer matches the
161
+ // workspace on disk is misconfigured, not contended: it must refuse
162
+ // immediately rather than queue behind a live provisioner for what can
163
+ // be minutes. Running it here also keeps the guarantee the guard has
164
+ // always had — no code path reaches initializeWorkspace without it
165
+ // having run in THIS process.
166
+ await assertSettingsWorkspaceIdentity(options);
167
+ // Layer 2: cross-process/cross-host lock around the init itself
168
+ // (proper-lockfile: heartbeat-refreshed while the holder lives, so a
169
+ // slow EFS clone is not mistaken for a crash, and patient retries so a
170
+ // loser WAITS instead of racing into a concurrent clone).
171
+ //
172
+ // This replaced a bespoke O_CREAT|O_EXCL + 30s-mtime scheme whose
173
+ // return value was only ever used to decide whether to release: the
174
+ // loser proceeded into initializeWorkspace anyway, concurrently with
175
+ // the holder, where it could see a half-written .git, classify it
176
+ // corrupt, and `rm -rf` it out from under the in-flight clone.
177
+ const releaseLock = await acquireProvisioningLock(settingsInitLockTarget(options.settingsRoot), SETTINGS_INIT_LOCK_NAME);
105
178
  try {
179
+ // Re-run the guard on the now-stable state. If we waited above, the
180
+ // previous holder may have created the workspace (or moved it onto
181
+ // its own settings branch) after we sampled it, and acting on that
182
+ // stale sample is exactly the destructive path the guard exists to
183
+ // stop. Still not gated on any "did I win the race" flag — there is
184
+ // no such flag by design; every process either holds the lock here
185
+ // or has already thrown.
186
+ await assertSettingsWorkspaceIdentity(options);
106
187
  // GitManager.initializeWorkspace is idempotent (checks for .git),
107
188
  // so it's safe to call even if another process just finished init.
108
189
  await GitManager.initializeWorkspace({
@@ -121,8 +202,11 @@ export class SettingsWorkspaceManager {
121
202
  });
122
203
  }
123
204
  finally {
124
- if (acquired) {
125
- await releaseFileLock(lockPath);
205
+ try {
206
+ await releaseLock();
207
+ }
208
+ catch (err) {
209
+ log.debug('workspace', 'Failed to release settings-init lock', { err });
126
210
  }
127
211
  }
128
212
  }
@@ -1 +1 @@
1
- {"version":3,"file":"settings-workspace.js","sourceRoot":"","sources":["../src/settings-workspace.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"settings-workspace.js","sourceRoot":"","sources":["../src/settings-workspace.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import type { ContentId } from './paths/types';
2
2
  export type CanopyUserId = string;
3
3
  export type CanopyGroupId = string;
4
- export type BranchStatus = 'editing' | 'submitted' | 'approved' | 'locked' | 'archived';
4
+ /**
5
+ * Branch workflow state. Anything other than 'editing' locks content writes --
6
+ * see authorization/protected-branch.ts `writeBlocked`, the single place that
7
+ * rule is expressed.
8
+ *
9
+ * There is deliberately no separate 'locked' state: 'submitted' already means
10
+ * "locked while a reviewer looks at the PR", and request-changes/withdraw are
11
+ * the unlocks. A future admin-freeze feature should reintroduce a status only
12
+ * alongside real semantics (worker rebase skip list, a set/unset endpoint, UI).
13
+ */
14
+ export type BranchStatus = 'editing' | 'submitted' | 'approved' | 'archived';
5
15
  export type SyncStatus = 'synced' | 'pending-sync' | 'sync-failed';
6
16
  export type ConflictStatus = 'clean' | 'conflicts-detected';
7
17
  export type PullRequestState = 'open' | 'closed' | 'merged';
@@ -9,7 +19,6 @@ export interface BranchAccessControl {
9
19
  allowedUsers?: CanopyUserId[];
10
20
  allowedGroups?: CanopyGroupId[];
11
21
  managerOrAdminAllowed?: boolean;
12
- adminOnly?: boolean;
13
22
  }
14
23
  export interface BranchMetadata {
15
24
  name: string;
@@ -52,6 +61,33 @@ export interface BranchMetadata {
52
61
  firstAt: string;
53
62
  lastAt: string;
54
63
  };
64
+ /**
65
+ * Set by the worker's rebase loop when it rewrote history this deployment
66
+ * had ALREADY published: the commit `remote.git` (and therefore GitHub)
67
+ * held for this branch, which the rebase replaced.
68
+ *
69
+ * Used as the `--force-with-lease` expected value on both hops, so a forced
70
+ * push can only ever move a ref off the exact commit our own rebase rewrote
71
+ * away -- never over anyone else's work.
72
+ *
73
+ * Set once per rewrite episode and never advanced while still set: across
74
+ * two rebases before any push lands, GitHub still holds the ORIGINAL
75
+ * commit, so advancing the marker would aim the lease at a commit GitHub
76
+ * never had. Cleared only once GitHub is confirmed to hold something else
77
+ * (CmsWorker.pushBranchToGitHub) -- while it is set, it is the sole trigger
78
+ * for the rebase loop's self-heal pass.
79
+ */
80
+ historyRewrittenFrom?: string;
81
+ /**
82
+ * Short, sanitized reason the worker's last GitHub sync task failed
83
+ * permanently (set alongside `syncStatus: 'sync-failed'` by
84
+ * CmsWorker.updateBranchMetadataOnFailure) -- e.g. a non-fast-forward push
85
+ * rejection naming the branch. Absent until a task has failed permanently.
86
+ * Cleared on the next successful sync task (CmsWorker.updateBranchMetadata
87
+ * explicitly resets it to undefined) so a stale reason never survives a
88
+ * later successful push.
89
+ */
90
+ syncFailureReason?: string;
55
91
  }
56
92
  export interface BranchPaths {
57
93
  /** Root where all branches live (e.g., /mnt/efs/site, ~/.canopycms/branches) */
@@ -88,10 +124,45 @@ export interface WorkerStatusReport {
88
124
  durationMs: number;
89
125
  rebased: string[];
90
126
  skippedDirty: string[];
127
+ /**
128
+ * [SYNC-C1] Branches skipped because a content write held the branch's
129
+ * cross-host content-write lock (utils/content-write-lock.ts) -- the
130
+ * worker yields and retries next cycle.
131
+ *
132
+ * Optional for the same reason as `tracked` below: absent from a
133
+ * worker-status.json written by a worker predating that lock. Readers must
134
+ * tolerate its absence.
135
+ */
136
+ skippedLocked?: string[];
91
137
  failed: {
92
138
  branch: string;
93
139
  error: string;
94
140
  }[];
141
+ /**
142
+ * Outcome of reconciling remote.git's `refs/heads/*` against GitHub's
143
+ * fetched tips (worker/cms-worker.ts's `reconcileTrackedBranches`) --
144
+ * the non-destructive replacement for the old fetch refspec that used
145
+ * to write GitHub's refs directly into `refs/heads/*`.
146
+ */
147
+ /**
148
+ * Optional: absent from a worker-status.json written by a worker that
149
+ * predates the tracked-branch reconcile (PR #168). Readers must tolerate
150
+ * its absence rather than assume every status file has it.
151
+ */
152
+ tracked?: {
153
+ created: string[];
154
+ fastForwarded: string[];
155
+ ahead: string[];
156
+ diverged: string[];
157
+ /**
158
+ * Optional for the same reason as `tracked` itself: absent from a
159
+ * status file written before the rebase loop began publishing rewritten
160
+ * history. Branches here diverged from GitHub because THIS worker
161
+ * rebased them and the GitHub push is still queued -- expected and
162
+ * self-resolving, unlike `diverged`.
163
+ */
164
+ rewritten?: string[];
165
+ };
95
166
  };
96
167
  lastFatalError?: {
97
168
  message: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,MAAM,YAAY,GAAG,MAAM,CAAA;AACjC,MAAM,MAAM,aAAa,GAAG,MAAM,CAAA;AAElC;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,CAAA;AAC5E,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,cAAc,GAAG,aAAa,CAAA;AAClE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,oBAAoB,CAAA;AAC3D,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE3D,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,YAAY,EAAE,CAAA;IAC7B,aAAa,CAAC,EAAE,aAAa,EAAE,CAAA;IAC/B,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,YAAY,CAAA;IACpB,MAAM,EAAE,mBAAmB,CAAA;IAC3B,SAAS,EAAE,YAAY,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,iFAAiF;IACjF,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,cAAc,CAAA;IAC/B,8FAA8F;IAC9F,aAAa,CAAC,EAAE,SAAS,EAAE,CAAA;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACpE;;;;;;;;;;;;;;;OAeG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAA;IAEhB,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAc,SAAQ,WAAW;IAChD,MAAM,EAAE,cAAc,CAAA;IAEtB,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,UAAU,EAAE,cAAc,EAAE,CAAA;CACjD;AAED,6FAA6F;AAC7F,MAAM,WAAW,uBAAwB,SAAQ,aAAa;IAC5D,UAAU,EAAE,OAAO,UAAU,EAAE,cAAc,EAAE,CAAA;CAChD;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,CAAC,CAAA;IACV,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,gBAAgB,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;IAClD,WAAW,CAAC,EAAE;QACZ,UAAU,EAAE,MAAM,CAAA;QAClB,OAAO,EAAE,MAAM,EAAE,CAAA;QACjB,YAAY,EAAE,MAAM,EAAE,CAAA;QACtB;;;;;;;;WAQG;QACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;QACxB,MAAM,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;QAC3C;;;;;WAKG;QACH;;;;WAIG;QACH,OAAO,CAAC,EAAE;YACR,OAAO,EAAE,MAAM,EAAE,CAAA;YACjB,aAAa,EAAE,MAAM,EAAE,CAAA;YACvB,KAAK,EAAE,MAAM,EAAE,CAAA;YACf,QAAQ,EAAE,MAAM,EAAE,CAAA;YAClB;;;;;;eAMG;YACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;SACrB,CAAA;KACF,CAAA;IACD,cAAc,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAA;KAAE,CAAA;CAC3E"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * [SYNC-C1] Cross-host mutual exclusion between the worker's rebase loop and
3
+ * content writes against the same branch working tree.
4
+ *
5
+ * Production runs a Lambda (API) and an EC2 worker over one EFS filesystem.
6
+ * `ContentStore`'s write/delete/rename critical sections were guarded only by
7
+ * the in-process mutex (`utils/async-mutex`), which does not cross that
8
+ * boundary, while `CmsWorker.rebaseActiveBranches()` rewrites the very same
9
+ * working tree. A write landing after `git rebase` started is destroyed two
10
+ * ways -- `git checkout --theirs <file>` overwrites it with the branch's
11
+ * committed version (and the rebase then SUCCEEDS, logging nothing), and
12
+ * `git rebase --abort` hard-resets the tree. Either way the editor already got
13
+ * its 200. This lock is what serializes the two.
14
+ *
15
+ * **Asymmetric by design.** The worker retries every branch on its next sync
16
+ * cycle (~5 minutes) while the editor is a person waiting on a save, so the
17
+ * worker yields and the writer waits:
18
+ *
19
+ * - Worker: {@link tryAcquireContentWriteLock} -- zero retries. On contention
20
+ * it skips that branch this cycle (an extension of the existing
21
+ * skip-dirty-branches behavior) and picks it up next cycle.
22
+ * - Writers: {@link withContentWriteLock} -- a short bounded wait, then a
23
+ * retriable failure the API surfaces as a 409.
24
+ *
25
+ * **Reads never take this lock.** An extra EFS round-trip on every read is not
26
+ * an acceptable price, and a read racing a rebase gets a consistent-enough
27
+ * older or newer file, never a destroyed one.
28
+ *
29
+ * ## Why the lock is anchored on `{branchRoot}/.canopy-meta`
30
+ *
31
+ * proper-lockfile keys its module-level `locks{}` bookkeeping (refresh timer,
32
+ * release function) by the **target path** passed to `lock()`, NOT by
33
+ * `lockfilePath`. The provisioning lock targets `path.dirname(branchRoot)` --
34
+ * the shared content-branches directory -- so anchoring here on that same
35
+ * directory with a different lock NAME would land on the same registry key:
36
+ * one process holding both (a Lambda container provisioning a workspace and
37
+ * writing content) would have the second acquisition clobber the first's
38
+ * bookkeeping. Targeting `{branchRoot}/.canopy-meta` instead gives:
39
+ *
40
+ * - a distinct in-process registry key from the provisioning lock (and from
41
+ * every other branch's content lock -- the provisioning locks already share
42
+ * one key across branches);
43
+ * - a distinct on-disk lock directory, so the server-enforced mkdir cannot
44
+ * collide either; and
45
+ * - no deadlock potential: the two locks are never both required, and the only
46
+ * possible acquisition order (provision, then write) is consistent.
47
+ *
48
+ * `.canopy-meta/` is git-excluded in every branch clone (`ensureGitExclude`),
49
+ * so the lock directory can never dirty the working tree or be swept into
50
+ * `git add .` at publish time.
51
+ *
52
+ * ## Honest caveat (do not oversell this)
53
+ *
54
+ * `proper-lockfile` decides a holder is dead by reading the lock directory's
55
+ * mtime with `fs.stat`, which on EFS is served through the NFS attribute
56
+ * cache. A live holder refreshes every `stale/2`, but a waiter can read a
57
+ * cached mtime and conclude the lock is stale when it is not, taking it over.
58
+ * The failure mode of a bad takeover is exactly today's behavior (two
59
+ * unsynchronized writers), so this is a strict improvement -- not a proof of
60
+ * mutual exclusion. See docs/concurrency.md.
61
+ */
62
+ /** On-disk name of the lock marker (a directory, created by mkdir). */
63
+ export declare const CONTENT_WRITE_LOCK_NAME = "content-write.lock";
64
+ /**
65
+ * Default bounded wait for a content write.
66
+ *
67
+ * Deliberately short. A rebase holds this lock for a fetch plus a replay plus
68
+ * N conflict rounds of git subprocesses on EFS -- far longer than any wait an
69
+ * interactive save can absorb, and longer than the API's own latency budget.
70
+ * So the wait is not sized to outlast a rebase (nothing reasonable could); it
71
+ * is sized to absorb lock handoff and the short holds of other writers, and to
72
+ * turn everything longer into a fast, explicit "retry" the editor can act on
73
+ * rather than a request that hangs toward the Lambda timeout. The worker
74
+ * revisits the branch next cycle, so the blocked state is transient.
75
+ *
76
+ * This is ALSO the writer-vs-writer budget, not only the rebase one. The lock
77
+ * is per-branch-root, so every write to one branch now serializes behind it
78
+ * where in-process serialization used to be per-entry -- bounded on Lambda
79
+ * (one invocation per container), but real under `next dev` and build-time
80
+ * provisioning across worker processes. The case to watch is a write whose
81
+ * in-lock path triggers a full `idIndex()` rescan of a large tree over EFS:
82
+ * that can plausibly exceed this budget and start 409ing unrelated saves.
83
+ * Making it configurable for prod tuning is tracked in
84
+ * .claude/future-tasks/content-write-lock-tuning-and-granularity.md.
85
+ */
86
+ export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
87
+ /**
88
+ * Thrown when the bounded wait expires with the branch's content lock still
89
+ * held. Retriable -- callers translate this into a 409 with a message that
90
+ * says so.
91
+ *
92
+ * The message says "syncing OR another save" because BOTH produce it. The
93
+ * lock is taken by `write`/`delete`/`renameEntry` and by the admin
94
+ * repair-content-duplicates action as well as by the worker's rebase, so a
95
+ * message naming only the rebase was a confident claim about something that
96
+ * may not be happening. `api/content.ts` routes this error ahead of the
97
+ * generic conflict specifically so the editor sees this wording, which makes
98
+ * the wording load-bearing rather than cosmetic.
99
+ */
100
+ export declare class ContentWriteLockBusyError extends Error {
101
+ constructor(message?: string);
102
+ }
103
+ /**
104
+ * Acquire the branch's content-write lock WITHOUT waiting.
105
+ *
106
+ * Throws an error with `code === 'ELOCKED'` when a live holder has it. Used by
107
+ * the worker's rebase loop (which skips the branch and retries next cycle) and
108
+ * by tests standing in for a writer.
109
+ */
110
+ export declare function tryAcquireContentWriteLock(branchRoot: string): Promise<() => Promise<void>>;
111
+ /**
112
+ * Acquire the branch's content-write lock with a short bounded wait.
113
+ *
114
+ * Retries ONLY on genuine contention (`ELOCKED`), the same discipline
115
+ * `withOccFileLock` uses: proper-lockfile's own retry loop retries blindly on
116
+ * any error, which would burn the whole budget re-hitting e.g. ENOENT after
117
+ * the branch directory was deleted out from under the caller.
118
+ *
119
+ * @throws ContentWriteLockBusyError when the budget expires under contention.
120
+ */
121
+ export declare function acquireContentWriteLock(branchRoot: string, waitMs?: number): Promise<() => Promise<void>>;
122
+ /**
123
+ * Run `fn` holding the branch's content-write lock, releasing it in a
124
+ * `finally` so a throw can never strand it.
125
+ */
126
+ export declare function withContentWriteLock<T>(branchRoot: string, fn: () => Promise<T>, waitMs?: number): Promise<T>;
127
+ //# sourceMappingURL=content-write-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-write-lock.d.ts","sourceRoot":"","sources":["../../src/utils/content-write-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AAUH,uEAAuE;AACvE,eAAO,MAAM,uBAAuB,uBAAuB,CAAA;AAE3D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,kCAAkC,OAAO,CAAA;AAEtD;;;;;;;;;;;;GAYG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;gBAEhD,OAAO,SAAuI;CAKjJ;AAYD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAE3F;AAED;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAA2C,GAClD,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAe9B;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,MAAM,GAAE,MAA2C,GAClD,OAAO,CAAC,CAAC,CAAC,CAOZ"}