canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -5,7 +5,9 @@
5
5
  * deletes every comment in the file, because comments live in neither the object nor that round
6
6
  * trip. So these functions re-serialise onto the file's OWN parsed document: a node whose value
7
7
  * did not change is left untouched, and an untouched node keeps its attached comments and its
8
- * original quoting/block style. Only what actually changed is rewritten.
8
+ * original quoting/block style. Only what actually changed is rewritten, and
9
+ * `yaml-source-splice.ts` writes it into the file's own text, so untouched lines keep their bytes
10
+ * wherever it can vouch for the result.
9
11
  *
10
12
  * The file gets no authority over its own content. The reconciler makes the document's key set
11
13
  * match `data` exactly — a key the caller dropped disappears, a key the caller kept survives
@@ -14,9 +16,14 @@
14
16
  * is a schema question, answered one layer up by `findUnknownKeys`
15
17
  * (validation/entry-validator.ts) at the API boundary, not by a schema-blind serialiser.
16
18
  */
19
+ import { isDeepStrictEqual } from 'node:util';
17
20
  import matter from 'gray-matter';
18
- import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, stringify as yamlStringify, } from 'yaml';
21
+ import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, Scalar, stringify as yamlStringify, } from 'yaml';
19
22
  import { isBlockStructuralKey } from '../validation/block-structural-keys.js';
23
+ import { createDebugLogger } from './debug.js';
24
+ import { getErrorMessage } from './error.js';
25
+ import { snapshotDocument, spliceSource, withSourceLineEndings } from './yaml-source-splice.js';
26
+ const log = createDebugLogger({ prefix: 'ContentSerialize' });
20
27
  /**
21
28
  * True only for a PLAIN object — one that should be reconciled key-by-key against a YAML map.
22
29
  *
@@ -187,13 +194,13 @@ function looksLikeSameItem(node, value) {
187
194
  * Reconcile one slot of the document against the value that must occupy it, returning the node to
188
195
  * put there. `existing` is the node in that slot, or null/undefined for a slot that did not exist.
189
196
  */
190
- function reconcileNode(doc, existing, value) {
197
+ function reconcileNode(ctx, existing, value) {
191
198
  if (isMap(existing) && isPlainRecord(value)) {
192
- reconcileMap(doc, existing, value);
199
+ reconcileMap(ctx, existing, value);
193
200
  return existing;
194
201
  }
195
202
  if (isSeq(existing) && Array.isArray(value)) {
196
- reconcileSeq(doc, existing, value);
203
+ reconcileSeq(ctx, existing, value);
197
204
  return existing;
198
205
  }
199
206
  // Unchanged scalar: return the node itself, untouched. This is the case that preserves comments
@@ -203,7 +210,16 @@ function reconcileNode(doc, existing, value) {
203
210
  // Changed, or a shape change (scalar <-> collection). A fresh node rather than mutating
204
211
  // `scalar.value` in place, which would keep the old node's representation and emit `'42'` where
205
212
  // the number 42 was meant.
206
- const fresh = doc.createNode(value);
213
+ const fresh = ctx.doc.createNode(value);
214
+ if (isNode(existing))
215
+ ctx.replaced.set(fresh, existing);
216
+ if (isScalar(existing) && isScalar(fresh) && typeof value === 'string') {
217
+ const style = carriedStyle(existing, value);
218
+ if (style !== undefined) {
219
+ fresh.type = style;
220
+ ctx.restyled.push(fresh);
221
+ }
222
+ }
207
223
  // Comments move with a changed VALUE, but not off a replaced STRUCTURE. `yaml` attaches a
208
224
  // comment written above a collection's first entry to the collection node itself, so that
209
225
  // node's comments are about its innards; carrying them onto whatever replaces the collection
@@ -214,12 +230,33 @@ function reconcileNode(doc, existing, value) {
214
230
  carryComments(existing, fresh);
215
231
  return fresh;
216
232
  }
233
+ /**
234
+ * The style a changed string inherits from the string it replaces: its quoting, or its block
235
+ * style (`>-`, `|`) unless the new value has no content or leads with whitespace, which `yaml`
236
+ * can print lossily in a block (`" "` reads back as `""`). Never `PLAIN`: forcing it stops `yaml` choosing a
237
+ * block for a multi-line value, and some (`Requirements:\nBring a laptop`) then print as
238
+ * unparseable YAML. An unset style is plain where plain is safe.
239
+ */
240
+ function carriedStyle(existing, value) {
241
+ if (typeof existing.value !== 'string')
242
+ return undefined;
243
+ switch (existing.type) {
244
+ case Scalar.QUOTE_DOUBLE:
245
+ case Scalar.QUOTE_SINGLE:
246
+ return existing.type;
247
+ case Scalar.BLOCK_FOLDED:
248
+ case Scalar.BLOCK_LITERAL:
249
+ return /\S/.test(value) && !/^\s/.test(value) ? existing.type : undefined;
250
+ default:
251
+ return undefined;
252
+ }
253
+ }
217
254
  /**
218
255
  * Make a map's key set match `value` exactly. Retained pairs are reconciled in place, so their key
219
256
  * order and the comments attached to their keys survive; keys new to `value` are appended in
220
257
  * `value` order, keeping a save's diff down to the lines that actually changed.
221
258
  */
222
- function reconcileMap(doc, map, value) {
259
+ function reconcileMap(ctx, map, value) {
223
260
  // An explicitly-undefined key is NOT a key: `Object.keys` reports it but `JSON.stringify` and
224
261
  // `yaml.stringify` omit it, so without this filter a key present on disk and set to `undefined`
225
262
  // in the payload is rewritten as `key: null` here while the create path drops it. Same rule on
@@ -234,14 +271,14 @@ function reconcileMap(doc, map, value) {
234
271
  if (key === undefined || !wanted.has(key) || seen.has(key))
235
272
  continue;
236
273
  seen.add(key);
237
- pair.value = reconcileNode(doc, pair.value, value[key]);
274
+ pair.value = reconcileNode(ctx, pair.value, value[key]);
238
275
  retained.push(pair);
239
276
  }
240
277
  map.items = retained;
241
278
  for (const key of Object.keys(value)) {
242
279
  if (seen.has(key) || !wanted.has(key))
243
280
  continue;
244
- map.set(doc.createNode(key), doc.createNode(value[key]));
281
+ map.set(ctx.doc.createNode(key), ctx.doc.createNode(value[key]));
245
282
  }
246
283
  }
247
284
  /**
@@ -261,7 +298,7 @@ function reconcileMap(doc, map, value) {
261
298
  * replacement lands on the index it replaced.
262
299
  * 3. **Otherwise a fresh node, with no comments.**
263
300
  */
264
- function reconcileSeq(doc, seq, value) {
301
+ function reconcileSeq(ctx, seq, value) {
265
302
  const oldItems = seq.items;
266
303
  // Old indices by identity, in order, so equal items are consumed first-come-first-served.
267
304
  const byIdentity = new Map();
@@ -298,28 +335,67 @@ function reconcileSeq(doc, seq, value) {
298
335
  // unclaimed?". Each iteration owns a distinct index, so a candidate cannot be taken twice.
299
336
  const candidate = index < oldItems.length && !consumed.has(index) ? oldItems[index] : undefined;
300
337
  const sameItem = candidate !== undefined && looksLikeSameItem(candidate, item) ? candidate : undefined;
301
- return reconcileNode(doc, sameItem, item);
338
+ return reconcileNode(ctx, sameItem, item);
302
339
  });
303
340
  }
304
- /** Apply `data` onto a parsed document, preserving every node the data did not change. */
305
- function applyDataToDocument(doc, data) {
306
- doc.contents = reconcileNode(doc, doc.contents, data);
341
+ /**
342
+ * Re-serialise `data` onto the YAML text `raw`, or undefined when `raw` does not parse or the
343
+ * reconciled document cannot be printed so that it reads back as its own data.
344
+ *
345
+ * The reconciler decides WHAT changes; {@link spliceSource} then writes those changes into the
346
+ * source text, so untouched lines keep the author's own folding and spacing. When it cannot
347
+ * vouch for a splice, the result is the reconciled document's own `toString()`, which is itself
348
+ * kept only if it parses back to the same data: a carried style that `yaml` prints lossily is
349
+ * dropped and the document re-printed, and failing that the caller writes a plain stringify.
350
+ * The worst case is a re-folded file, or one without its comments; never different data.
351
+ * `rootAtColumnZero` refuses a splice whose first line is indented, for frontmatter, whose
352
+ * framing trims the first line's indentation.
353
+ */
354
+ function reconcileYamlSource(raw, data, rootAtColumnZero = false) {
355
+ const doc = parseDocument(raw);
356
+ if (doc.errors.length > 0)
357
+ return undefined;
358
+ const snapshot = snapshotDocument(doc);
359
+ const ctx = { doc, replaced: new WeakMap(), restyled: [] };
360
+ doc.contents = reconcileNode(ctx, doc.contents, data);
361
+ let reconciled = withSourceLineEndings(doc.toString(), raw);
362
+ if (!readsBackAs(reconciled, doc)) {
363
+ for (const scalar of ctx.restyled)
364
+ scalar.type = undefined;
365
+ reconciled = withSourceLineEndings(doc.toString(), raw);
366
+ if (!readsBackAs(reconciled, doc))
367
+ return undefined;
368
+ }
369
+ try {
370
+ const spliced = spliceSource(raw, doc, snapshot, ctx.replaced, reconciled);
371
+ if (spliced !== undefined && !(rootAtColumnZero && /^\s*[ \t]\S/.test(spliced))) {
372
+ return spliced;
373
+ }
374
+ log.debug('content-serialize', 'source splice not used; writing as yaml prints it');
375
+ }
376
+ catch (err) {
377
+ log.debug('content-serialize', 'source splice failed; writing as yaml prints it', {
378
+ error: getErrorMessage(err),
379
+ });
380
+ }
381
+ return reconciled;
382
+ }
383
+ function readsBackAs(text, doc) {
384
+ const reparsed = parseDocument(text);
385
+ return reparsed.errors.length === 0 && isDeepStrictEqual(reparsed.toJS(), doc.toJS());
307
386
  }
308
387
  /**
309
388
  * Serialise entry data as a YAML file, carrying the comments of `existingRaw` through.
310
389
  *
311
390
  * Falls back to a plain stringify — byte-identical to serialising without preservation — when
312
391
  * there is nothing to preserve (a new file) or nothing trustworthy to preserve (the bytes on disk
313
- * do not parse). A save must not fail because the previous content was malformed.
392
+ * do not parse, or the reconciled document does not read back as its own data). A save must not
393
+ * fail because the previous content was malformed.
314
394
  */
315
395
  export function serializeYaml(data, existingRaw) {
316
396
  if (existingRaw === undefined)
317
397
  return yamlStringify(data);
318
- const doc = parseDocument(existingRaw);
319
- if (doc.errors.length > 0)
320
- return yamlStringify(data);
321
- applyDataToDocument(doc, data);
322
- return doc.toString();
398
+ return reconcileYamlSource(existingRaw, data) ?? yamlStringify(data);
323
399
  }
324
400
  /**
325
401
  * Split a file into its raw frontmatter string, or undefined when there is nothing usable. Two
@@ -362,11 +438,9 @@ export function serializeFrontmatter(body, data, existingRaw) {
362
438
  const existingFrontmatter = extractRawFrontmatter(existingRaw);
363
439
  if (existingFrontmatter === undefined)
364
440
  return matter.stringify(body, data);
365
- const doc = parseDocument(existingFrontmatter);
366
- if (doc.errors.length > 0)
441
+ const reconciled = reconcileYamlSource(existingFrontmatter, data, true);
442
+ if (reconciled === undefined)
367
443
  return matter.stringify(body, data);
368
- applyDataToDocument(doc, data);
369
- const reconciled = doc.toString();
370
444
  return matter.stringify(body, data, {
371
445
  engines: {
372
446
  yaml: {
@@ -21,9 +21,10 @@
21
21
  *
22
22
  * The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
23
23
  * every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
24
- * lock; the two are never both required and the only possible order (provision, then write) is
25
- * consistent, so they cannot deadlock. `.canopy-meta/` is git-excluded in every branch clone
26
- * (`ensureGitExclude`), so the lock directory cannot dirty the tree or be swept into `git add .`.
24
+ * lock. The worker's rebase and base-branch refresh hold both, provisioning outside this one, and
25
+ * take each try-only; the global order is docs/concurrency.md's "Lock acquisition order".
26
+ * `.canopy-meta/` is git-excluded in every branch clone
27
+ * (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
27
28
  *
28
29
  * Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
29
30
  * leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
@@ -50,14 +51,24 @@ export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
50
51
  * Thrown when the bounded wait expires with the branch's content lock still held. Retriable --
51
52
  * callers translate it into a 409 with a message that says so.
52
53
  *
53
- * The message says "syncing OR another save" because BOTH produce it: the lock is taken by
54
- * `write`/`delete`/`renameEntry` and the admin repair-content-duplicates action as well as by the
55
- * worker's rebase, so naming only the rebase would claim more than is known. `api/content.ts`
54
+ * The message says "syncing OR another save" because BOTH produce it: the lock is taken by every
55
+ * working-tree mutator (docs/concurrency.md, "Who takes it") as well as by the worker's rebase,
56
+ * so naming only the rebase would claim more than is known. `api/content.ts`
56
57
  * routes this error ahead of the generic conflict so the editor sees this wording, making it
57
58
  * load-bearing rather than cosmetic.
58
59
  */
59
60
  export declare class ContentWriteLockBusyError extends Error {
60
- constructor(message?: string);
61
+ /**
62
+ * `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
63
+ * but the lock was lost during it (see {@link withContentWriteLock}).
64
+ */
65
+ readonly outcome: 'not-run' | 'unknown';
66
+ constructor(message?: string,
67
+ /**
68
+ * `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
69
+ * but the lock was lost during it (see {@link withContentWriteLock}).
70
+ */
71
+ outcome?: 'not-run' | 'unknown');
61
72
  }
62
73
  /**
63
74
  * Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
@@ -21,9 +21,10 @@
21
21
  *
22
22
  * The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
23
23
  * every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
24
- * lock; the two are never both required and the only possible order (provision, then write) is
25
- * consistent, so they cannot deadlock. `.canopy-meta/` is git-excluded in every branch clone
26
- * (`ensureGitExclude`), so the lock directory cannot dirty the tree or be swept into `git add .`.
24
+ * lock. The worker's rebase and base-branch refresh hold both, provisioning outside this one, and
25
+ * take each try-only; the global order is docs/concurrency.md's "Lock acquisition order".
26
+ * `.canopy-meta/` is git-excluded in every branch clone
27
+ * (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
27
28
  *
28
29
  * Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
29
30
  * leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
@@ -57,15 +58,21 @@ export const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
57
58
  * Thrown when the bounded wait expires with the branch's content lock still held. Retriable --
58
59
  * callers translate it into a 409 with a message that says so.
59
60
  *
60
- * The message says "syncing OR another save" because BOTH produce it: the lock is taken by
61
- * `write`/`delete`/`renameEntry` and the admin repair-content-duplicates action as well as by the
62
- * worker's rebase, so naming only the rebase would claim more than is known. `api/content.ts`
61
+ * The message says "syncing OR another save" because BOTH produce it: the lock is taken by every
62
+ * working-tree mutator (docs/concurrency.md, "Who takes it") as well as by the worker's rebase,
63
+ * so naming only the rebase would claim more than is known. `api/content.ts`
63
64
  * routes this error ahead of the generic conflict so the editor sees this wording, making it
64
65
  * load-bearing rather than cosmetic.
65
66
  */
66
67
  export class ContentWriteLockBusyError extends Error {
67
- constructor(message = 'This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.') {
68
+ constructor(message = 'This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.',
69
+ /**
70
+ * `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
71
+ * but the lock was lost during it (see {@link withContentWriteLock}).
72
+ */
73
+ outcome = 'not-run') {
68
74
  super(message);
75
+ this.outcome = outcome;
69
76
  this.name = 'ContentWriteLockBusyError';
70
77
  }
71
78
  }
@@ -77,12 +84,14 @@ function lockTargetDir(branchRoot) {
77
84
  const sleep = (ms) => new Promise((resolve) => {
78
85
  setTimeout(resolve, ms);
79
86
  });
87
+ /** Below provisioning's, so a crashed worker blocks saves for at most 30s (docs/concurrency.md). */
88
+ const CONTENT_WRITE_LOCK_STALE_MS = 30_000;
80
89
  /**
81
90
  * Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
82
91
  * skips the branch and retries next cycle. Throws with `code === 'ELOCKED'` on a live holder.
83
92
  */
84
93
  export function tryAcquireContentWriteLock(branchRoot, onCompromised) {
85
- return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
94
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS, false);
86
95
  }
87
96
  /**
88
97
  * Acquire the branch's content-write lock with a short bounded wait.
@@ -136,7 +145,7 @@ export async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONT
136
145
  // disk and only the proof of exclusivity is lost. "Reload, then decide" rather than "retry",
137
146
  // which would resend a now-stale expectedVersion and bounce off the caller's own landed write
138
147
  // as a phantom editor collision.
139
- throw new ContentWriteLockBusyError('This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.');
148
+ throw new ContentWriteLockBusyError('This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.', 'unknown');
140
149
  }
141
150
  return result;
142
151
  }
@@ -26,8 +26,16 @@ export declare class DebugLogger {
26
26
  info(category: string, message: string, data?: unknown): void;
27
27
  warn(category: string, message: string, data?: unknown): void;
28
28
  error(category: string, message: string, data?: unknown): void;
29
+ /**
30
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
31
+ * anything that can run concurrently uses {@link timed}.
32
+ */
29
33
  time(label: string): void;
30
34
  timeEnd(category: string, label: string): number | undefined;
35
+ /**
36
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
37
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
38
+ */
31
39
  timed<T>(category: string, label: string, fn: () => Promise<T>): Promise<T>;
32
40
  }
33
41
  export declare function createDebugLogger(options?: DebugOptions): DebugLogger;
@@ -55,6 +55,10 @@ export class DebugLogger {
55
55
  throw new Error(errorMsg);
56
56
  }
57
57
  }
58
+ /**
59
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
60
+ * anything that can run concurrently uses {@link timed}.
61
+ */
58
62
  time(label) {
59
63
  this.timers.set(label, Date.now());
60
64
  }
@@ -69,13 +73,17 @@ export class DebugLogger {
69
73
  this.debug(category, `${label} completed`, { durationMs: duration });
70
74
  return duration;
71
75
  }
76
+ /**
77
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
78
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
79
+ */
72
80
  async timed(category, label, fn) {
73
- this.time(label);
81
+ const start = Date.now();
74
82
  try {
75
83
  return await fn();
76
84
  }
77
85
  finally {
78
- this.timeEnd(category, label);
86
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
79
87
  }
80
88
  }
81
89
  }
@@ -1,3 +1,4 @@
1
+ import { type SimpleGit } from 'simple-git';
1
2
  import type { OperatingMode } from '../operating-mode/index.js';
2
3
  /**
3
4
  * Whether a git remote URL points at a network location rather than a local filesystem path. Only
@@ -39,6 +40,15 @@ export declare function isNonFastForwardRejection(message: string): boolean;
39
40
  * surfacing as the permanent, human-actionable state it is. Same locale caveat as above.
40
41
  */
41
42
  export declare function isStaleLeaseRejection(message: string): boolean;
43
+ /**
44
+ * The workflow file named by GitHub's refusal of a push that would add workflow content the
45
+ * credential lacks the workflows permission for, or null if the message is not that refusal.
46
+ *
47
+ * GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
48
+ * rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
49
+ * worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
50
+ */
51
+ export declare function workflowPushRefusalFile(message: string): string | null;
42
52
  /**
43
53
  * Whether a `git fetch <remote> <branch>` failure is specifically "that ref doesn't exist on the
44
54
  * remote" -- the ONLY benign fetch outcome, meaning the branch has never been pushed.
@@ -50,6 +60,28 @@ export declare function isStaleLeaseRejection(message: string): boolean;
50
60
  * caveat as the predicates above.
51
61
  */
52
62
  export declare function isMissingRemoteRefFailure(message: string): boolean;
63
+ /**
64
+ * The directory, at every workspace root, holding canopycms's own per-workspace state: branch
65
+ * metadata, comments, generation markers and lock markers. None of it is content, so it never
66
+ * belongs in a commit.
67
+ */
68
+ export declare const CANOPY_META_DIR = ".canopy-meta";
69
+ /**
70
+ * Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
71
+ * or lies under it.
72
+ */
73
+ export declare function isCanopyInternalPath(repoRelativePath: string): boolean;
74
+ /**
75
+ * Stage every working-tree change (additions, edits, deletions) except anything under
76
+ * {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
77
+ * unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
78
+ * the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
79
+ *
80
+ * `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
81
+ * With it, such a file is staged like any other, and a tracked file absent only because it is
82
+ * outside the cone is still not staged as a deletion. A no-op in a full clone.
83
+ */
84
+ export declare function stageAllExceptCanopyState(git: SimpleGit): Promise<void>;
53
85
  /**
54
86
  * Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
55
87
  * backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
package/dist/utils/git.js CHANGED
@@ -88,6 +88,21 @@ const STALE_LEASE_REASON = 'stale info';
88
88
  export function isStaleLeaseRejection(message) {
89
89
  return message.includes(REJECTED_MARKER) && message.includes(STALE_LEASE_REASON);
90
90
  }
91
+ // GitHub's reason text when a push would introduce workflow content the credential may not write.
92
+ // It names the credential kind ("a GitHub App", "an OAuth App", ...) and then the file.
93
+ // Both classes stop at a newline, so the match stays on the one status line that carries it.
94
+ const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or update workflow `([^`\n]+)`/;
95
+ /**
96
+ * The workflow file named by GitHub's refusal of a push that would add workflow content the
97
+ * credential lacks the workflows permission for, or null if the message is not that refusal.
98
+ *
99
+ * GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
100
+ * rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
101
+ * worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
102
+ */
103
+ export function workflowPushRefusalFile(message) {
104
+ return WORKFLOW_REFUSAL_PATTERN.exec(message)?.[1] ?? null;
105
+ }
91
106
  // git's message when `git fetch <remote> <branch>` names a ref the remote does not have. Both
92
107
  // spellings occur: modern git prints the lowercase form, older versions and some transports
93
108
  // capitalize it.
@@ -135,6 +150,33 @@ async function resolveGitDir(repoPath) {
135
150
  return null;
136
151
  }
137
152
  }
153
+ /**
154
+ * The directory, at every workspace root, holding canopycms's own per-workspace state: branch
155
+ * metadata, comments, generation markers and lock markers. None of it is content, so it never
156
+ * belongs in a commit.
157
+ */
158
+ export const CANOPY_META_DIR = '.canopy-meta';
159
+ /**
160
+ * Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
161
+ * or lies under it.
162
+ */
163
+ export function isCanopyInternalPath(repoRelativePath) {
164
+ return repoRelativePath === CANOPY_META_DIR || repoRelativePath.startsWith(`${CANOPY_META_DIR}/`);
165
+ }
166
+ /**
167
+ * Stage every working-tree change (additions, edits, deletions) except anything under
168
+ * {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
169
+ * unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
170
+ * the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
171
+ *
172
+ * `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
173
+ * With it, such a file is staged like any other, and a tracked file absent only because it is
174
+ * outside the cone is still not staged as a deletion. A no-op in a full clone.
175
+ */
176
+ export async function stageAllExceptCanopyState(git) {
177
+ await git.raw(['add', '-A', '--sparse']);
178
+ await git.raw(['reset', '-q', '--', CANOPY_META_DIR]);
179
+ }
138
180
  /**
139
181
  * Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
140
182
  * backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
@@ -158,8 +158,16 @@ export async function withOccRetry(operation, options) {
158
158
  * legitimately take many seconds.
159
159
  */
160
160
  export async function withOccFileLock(filePath, fn) {
161
- const dir = path.dirname(filePath);
162
- await fs.mkdir(dir, { recursive: true });
161
+ // The lock's own directory only, never its ancestors: a waiter must not recreate a branch root
162
+ // that a delete removed under it (the same rule as the ENOENT case in the loop below).
163
+ try {
164
+ await fs.mkdir(path.dirname(filePath));
165
+ }
166
+ catch (err) {
167
+ if (!isNodeError(err) || err.code !== 'EEXIST') {
168
+ throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
169
+ }
170
+ }
163
171
  // Acquisition retries are OUR loop, not proper-lockfile's built-in
164
172
  // `retries`: the built-in loop retries blindly on ANY error, so a waiter
165
173
  // whose target directory was deleted mid-poll (deleteBranch's rm removing
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Replace where step lines go; no argument restores `canopyLog`. The test
3
+ * setup silences them, since nearly every suite provisions a workspace.
4
+ * @internal Exported for tests.
5
+ */
6
+ export declare function setProvisionLogSink(next?: (line: string) => void): void;
7
+ /**
8
+ * Per-step timing for one workspace provisioning, printed unconditionally
9
+ * (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
10
+ * at its timeout, never reaches its own error handling, so each step prints a
11
+ * `start` line and a `done` line: the last line in the log names the step that
12
+ * was running.
13
+ *
14
+ * Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
15
+ * then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
16
+ */
17
+ export declare class ProvisionLog {
18
+ readonly id: string;
19
+ private readonly startedAt;
20
+ private readonly durations;
21
+ private readonly prefix;
22
+ constructor(dir: string);
23
+ step<T>(name: string, run: () => Promise<T>): Promise<T>;
24
+ finish(outcome: string): void;
25
+ }
@@ -0,0 +1,49 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { canopyLog } from './logger.js';
3
+ const defaultSink = (line) => canopyLog(line);
4
+ let sink = defaultSink;
5
+ /**
6
+ * Replace where step lines go; no argument restores `canopyLog`. The test
7
+ * setup silences them, since nearly every suite provisions a workspace.
8
+ * @internal Exported for tests.
9
+ */
10
+ export function setProvisionLogSink(next) {
11
+ sink = next ?? defaultSink;
12
+ }
13
+ /**
14
+ * Per-step timing for one workspace provisioning, printed unconditionally
15
+ * (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
16
+ * at its timeout, never reaches its own error handling, so each step prints a
17
+ * `start` line and a `done` line: the last line in the log names the step that
18
+ * was running.
19
+ *
20
+ * Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
21
+ * then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
22
+ */
23
+ export class ProvisionLog {
24
+ constructor(dir) {
25
+ this.id = randomBytes(3).toString('hex');
26
+ this.startedAt = Date.now();
27
+ this.durations = [];
28
+ this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
29
+ }
30
+ async step(name, run) {
31
+ sink(`${this.prefix} step=${name} start`);
32
+ const stepStartedAt = Date.now();
33
+ let state = 'failed';
34
+ try {
35
+ const result = await run();
36
+ state = 'done';
37
+ return result;
38
+ }
39
+ finally {
40
+ const ms = Date.now() - stepStartedAt;
41
+ this.durations.push(`${name}=${ms}`);
42
+ sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
43
+ }
44
+ }
45
+ finish(outcome) {
46
+ const total = Date.now() - this.startedAt;
47
+ sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(' ')}`.trim());
48
+ }
49
+ }
@@ -9,6 +9,19 @@
9
9
  * inside the refresh timer, so a throw is an uncaught exception that kills the process.
10
10
  */
11
11
  export type OnLockCompromised = (err: Error) => void;
12
+ /**
13
+ * When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
14
+ * which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
15
+ * can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
16
+ */
17
+ export declare const PROVISIONING_LOCK_STALE_MS = 90000;
18
+ /**
19
+ * The provisioning lock's name for the branch workspace directory `dirName`, held in that
20
+ * directory's parent. Every party to a branch workspace's provisioning names it through this:
21
+ * the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
22
+ * while it refreshes or rebases.
23
+ */
24
+ export declare function branchProvisioningLockName(dirName: string): string;
12
25
  /**
13
26
  * Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
14
27
  * always call it in a `finally`.
@@ -27,12 +40,29 @@ export declare function acquireProvisioningLock(lockTargetDir: string, lockName:
27
40
  * request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
28
41
  * budget waits minutes for a live provisioner; an admin request must fail fast instead.
29
42
  *
30
- * `stale: 30_000` is unchanged from the patient variant: a genuinely stale lock (holder crashed
31
- * more than 30s ago) is still taken over normally. Only the RETRY loop for live contention is
32
- * removed, not the staleness recovery a caller depends on -- see branch-health.ts's [H1]
33
- * freshness rail, which reads this lock's mtime before an admin purge/repair proceeds.
43
+ * Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
44
+ * over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
45
+ * caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
46
+ * before an admin purge/repair proceeds.
34
47
  *
35
48
  * Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
36
49
  * translate that into a 409.
50
+ *
51
+ * @param staleMs how old the marker must look before this caller takes it over; defaults to
52
+ * {@link PROVISIONING_LOCK_STALE_MS}
53
+ * @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
54
+ * branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
55
+ * parent fails with ENOENT instead.
56
+ */
57
+ export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised, staleMs?: number, createParents?: boolean): Promise<() => Promise<void>>;
58
+ /**
59
+ * {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
60
+ * milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
61
+ * {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
62
+ * whole timeout; this gives up instead.
63
+ *
64
+ * Retries only on `ELOCKED`, like the content-write lock's bounded wait.
65
+ *
66
+ * @throws the last `ELOCKED` error once the budget is spent
37
67
  */
38
- export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;
68
+ export declare function acquireProvisioningLockWithin(lockTargetDir: string, lockName: string, waitMs: number, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;