canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -4,11 +4,14 @@ import { simpleGit } from 'simple-git';
4
4
  import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
5
5
  import { extractIdFromFilename } from '../content-id-index.js';
6
6
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
7
+ import { isSettingsBranchName } from '../paths/branch-name.js';
7
8
  import { normalizeFilesystemPath } from '../paths/normalize.js';
8
9
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
9
10
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
11
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
10
12
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
11
- import { isRebaseInProgress } from '../utils/git.js';
13
+ import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
14
+ import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
12
15
  import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
13
16
  import { workerLog, workerLogWarn } from './log.js';
14
17
  /**
@@ -123,7 +126,7 @@ const MAX_REBASE_ROUNDS = 50;
123
126
  * - the **unexpected-error** exit aborts HERE, before breaking, so the caller's
124
127
  * `!completed` abort is a caught no-op for that path;
125
128
  * - the **lock-compromised** exit leaves the rebase in progress and is aborted
126
- * by `rebaseOneBranch`'s own `contentLockCompromised && !completed` branch,
129
+ * by `rebaseOneBranch`'s own `lockCompromised() && !completed` branch,
127
130
  * which returns `skippedLocked` before the `!completed` block below it is
128
131
  * reached -- a distinct site that cannot be folded into that one;
129
132
  * - the **conflict-resolution-failure** and **MAX_REBASE_ROUNDS** exits are the
@@ -159,8 +162,8 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
159
162
  break;
160
163
  try {
161
164
  if (nextAction === 'start') {
162
- // The pinned base tip fetched above (single-branch clones have
163
- // no origin/<base> remote-tracking ref for other branches).
165
+ // The pinned base tip fetched above; a fetch by path updates no
166
+ // remote-tracking ref.
164
167
  await branchGit.rebase([fetchedBaseTip]);
165
168
  }
166
169
  else if (nextAction === 'continue') {
@@ -196,20 +199,23 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
196
199
  //
197
200
  // A per-file resolution that STILL fails routes into the `!completed`
198
201
  // path below, which aborts and records, rather than escaping.
202
+ //
203
+ // `--sparse` on add/rm: a conflicted path outside a sparse clone's cone
204
+ // is materialised for the conflict, but git refuses to stage it without.
199
205
  const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
200
206
  let resolutionFailure;
201
207
  for (const file of st.conflicted) {
202
208
  const kind = conflictKind.get(file);
203
209
  try {
204
210
  if (kind === 'UD') {
205
- await branchGit.raw(['rm', '-f', '--', file]);
211
+ await branchGit.raw(['rm', '-f', '--sparse', '--', file]);
206
212
  }
207
213
  else if (kind === 'DU') {
208
- await branchGit.add(file);
214
+ await branchGit.raw(['add', '--sparse', '--', file]);
209
215
  }
210
216
  else {
211
217
  await branchGit.raw(['checkout', '--theirs', file]);
212
- await branchGit.add(file);
218
+ await branchGit.raw(['add', '--sparse', '--', file]);
213
219
  }
214
220
  }
215
221
  catch (resolveErr) {
@@ -406,7 +412,14 @@ export async function runRebaseCycle(ctx) {
406
412
  workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
407
413
  continue;
408
414
  }
409
- const outcome = await rebaseOneBranch(ctx, branchDir, branchPath);
415
+ // A settings branch is an orphan: rebasing it onto the base would give it content
416
+ // history. The API refuses to provision one, so a directory under that name is a
417
+ // leftover and is left alone.
418
+ if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
419
+ workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
420
+ continue;
421
+ }
422
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath);
410
423
  if (outcome.kind === 'rebased')
411
424
  rebased.push(branchDir);
412
425
  else if (outcome.kind === 'skippedDirty')
@@ -423,6 +436,39 @@ export async function runRebaseCycle(ctx) {
423
436
  }
424
437
  return { rebased, skippedDirty, skippedLocked, failed };
425
438
  }
439
+ /**
440
+ * {@link rebaseOneBranch} under the branch's provisioning lock, so no git step races a clone of
441
+ * this directory. A branch whose lock is held elsewhere is `skippedLocked` and retried next cycle;
442
+ * one with no `branch.json` is skipped. Lock order: provisioning, then the
443
+ * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
444
+ * Never throws, like rebaseOneBranch.
445
+ */
446
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
447
+ let hold;
448
+ try {
449
+ hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
450
+ }
451
+ catch (err) {
452
+ // [REDACT] failed[] is served to the browser via worker-status.json.
453
+ const error = redactCredentials(`could not take the provisioning lock: ${getErrorMessage(err)}`);
454
+ workerLogWarn(` Skipping ${branchDir}: ${error}`);
455
+ return { kind: 'failed', error };
456
+ }
457
+ if (hold.kind === 'locked') {
458
+ workerLog(` Skipping ${branchDir}: provisioning lock held elsewhere (retrying next cycle)`);
459
+ return { kind: 'skippedLocked' };
460
+ }
461
+ // Silent: a directory without branch.json is a staging-era leftover, which
462
+ // repairBranchDirResidue (git-sync.ts) quarantines and logs at the cycle's start.
463
+ if (hold.kind === 'not-provisioned')
464
+ return { kind: 'none' };
465
+ try {
466
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised);
467
+ }
468
+ finally {
469
+ await releaseProvisionedWorkspace(hold.release, branchDir);
470
+ }
471
+ }
426
472
  /**
427
473
  * Rebase ONE branch workspace onto the base branch, keeping the branch's
428
474
  * version of every conflicting file.
@@ -435,7 +481,7 @@ export async function runRebaseCycle(ctx) {
435
481
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
436
482
  * rest of the cycle ([SYNC-M2]).
437
483
  */
438
- async function rebaseOneBranch(ctx, branchDir, branchPath) {
484
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised) {
439
485
  // Read only by the catch below. See the `rebased` rider on
440
486
  // BranchRebaseOutcome.
441
487
  let didRebase = false;
@@ -489,6 +535,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
489
535
  // record it and bail before the next one rather than replaying over
490
536
  // an editor's concurrent save.
491
537
  let contentLockCompromised = false;
538
+ // Either lock lost means another writer may be live against this tree;
539
+ // both get the same bail-before-the-next-destructive-step treatment.
540
+ const lockCompromised = () => contentLockCompromised || isProvisioningLockCompromised();
492
541
  try {
493
542
  releaseContentLock = await tryAcquireContentWriteLock(branchPath, (lockErr) => {
494
543
  contentLockCompromised = true;
@@ -574,12 +623,37 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
574
623
  }
575
624
  // Skip dirty branches — the editor has changes that cannot be rebased.
576
625
  // Inside the lock, so no write can land between this check and the rebase
577
- // below.
578
- const dirtyCheck = await branchGit.status();
579
- if (dirtyCheck.files.length > 0) {
626
+ // below. canopycms's own untracked state is not dirt.
627
+ let dirtyCheck = await branchGit.status();
628
+ if (await restoreRetiredSchemaCache(branchGit, dirtyCheck)) {
629
+ workerLog(` ${branchDir}: restored the retired in-tree schema cache`);
630
+ dirtyCheck = await branchGit.status();
631
+ }
632
+ const editorDirt = dirtyCheck.files.filter((f) => !isCanopyInternalPath(f.path));
633
+ if (editorDirt.length > 0) {
580
634
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
581
635
  return { kind: 'skippedDirty' };
582
636
  }
637
+ // `git rebase` refuses to start over ANY modified tracked file, so
638
+ // tracked canopycms state blocks. The worker never untracks it here:
639
+ // replaying a branch commit that touched it writes historical bytes over
640
+ // the live file, and the abort that follows deletes it. So the index and
641
+ // the bytes stay untouched, and the wedge is recorded for an operator.
642
+ const trackedState = trackedCanopyStateChanges(dirtyCheck);
643
+ if (trackedState.length > 0) {
644
+ await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
645
+ const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
646
+ const { stillTracked } = await splitByUpstreamTracking(branchGit, trackedState, baseTip);
647
+ const reason = stillTracked.length > 0
648
+ ? `git cannot rebase over modified canopycms state the repo tracks ` +
649
+ `(${trackedState.join(', ')}). To fix, ${TRACKED_CANOPY_STATE_FIX}.`
650
+ : `git cannot rebase over modified canopycms state this clone still tracks ` +
651
+ `(${trackedState.join(', ')}), though the base branch no longer does; ` +
652
+ `the clone needs manual repair`;
653
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
654
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason);
655
+ return { kind: 'failed', error: reason };
656
+ }
583
657
  // The clone's own ref name: branchDir is the sanitized DIRECTORY name and
584
658
  // need not match it. A literal 'HEAD' means a detached clone (a crashed
585
659
  // rebase can leave one), where nothing below can safely name a ref, so
@@ -601,14 +675,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
601
675
  marker: metaFile.branch.historyRewrittenFrom,
602
676
  });
603
677
  }
604
- await branchGit.fetch('origin', ctx.baseBranch);
678
+ await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
605
679
  // rev-list, not status.behind, which needs an upstream tracking branch
606
680
  // that checkoutBranch's fallback paths do not always configure. Against
607
- // the just-fetched tip, not origin/<base>: branch clones are
608
- // --single-branch, so no remote-tracking ref exists for any base branch
609
- // other than the one they were cloned from. Pinned to a SHA immediately
610
- // — FETCH_HEAD is one shared mutable file per repo, repointed by any
611
- // concurrent fetch.
681
+ // the just-fetched tip: a fetch by path updates no remote-tracking ref.
682
+ // Pinned to a SHA immediately — FETCH_HEAD is one shared mutable file per
683
+ // repo, repointed by any concurrent fetch.
612
684
  const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
613
685
  const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
614
686
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
@@ -651,7 +723,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
651
723
  // the rebase makes unreadable.
652
724
  const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
653
725
  const publishedSha = canPublish ? await readPublishedSha(ctx, branchRef) : null;
654
- const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, () => contentLockCompromised);
726
+ const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
655
727
  // Outside the round loop's try/catch, so a throwing test hook can
656
728
  // never be misread as a rebase error.
657
729
  if (completed)
@@ -676,14 +748,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
676
748
  // history while a concurrent save is uncommitted working-tree state, and
677
749
  // that writer is already told to retry via ContentWriteLockBusyError. So
678
750
  // when the rebase completed, log the lost exclusivity loudly and finish.
679
- if (contentLockCompromised && !completed) {
680
- workerLogWarn(` Skipping ${branchDir}: content-write lock was compromised mid-rebase (retrying next cycle)`);
751
+ if (lockCompromised() && !completed) {
752
+ workerLogWarn(` Skipping ${branchDir}: a lock it held was compromised mid-rebase (retrying next cycle)`);
681
753
  // No-op when no rebase is in progress; failure is expected there.
682
754
  await branchGit.rebase(['--abort']).catch(() => { });
683
755
  return { kind: 'skippedLocked' };
684
756
  }
685
- if (contentLockCompromised) {
686
- workerLogWarn(` Content-write lock for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
757
+ if (lockCompromised()) {
758
+ workerLogWarn(` A lock held for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
687
759
  }
688
760
  if (!completed) {
689
761
  // failureReason is only set on the "unexpected error" break above;
@@ -0,0 +1,3 @@
1
+ import { type BareRemoteRepackResult } from '../git-manager.js';
2
+ /** The worker's per-cycle repack of `remote.git` (git-manager.ts owns the rule), logged. */
3
+ export declare function maintainRemoteGit(gitDir: string): Promise<BareRemoteRepackResult>;
@@ -0,0 +1,11 @@
1
+ import { repackBareRemoteIfNeeded } from '../git-manager.js';
2
+ import { workerLog } from './log.js';
3
+ /** The worker's per-cycle repack of `remote.git` (git-manager.ts owns the rule), logged. */
4
+ export async function maintainRemoteGit(gitDir) {
5
+ const result = await repackBareRemoteIfNeeded(gitDir);
6
+ if (result.repacked) {
7
+ workerLog(`remote.git maintenance: loose ${result.before.loose}→${result.after.loose} ` +
8
+ `packs ${result.before.packs}→${result.after.packs} in ${result.ms} ms`);
9
+ }
10
+ return result;
11
+ }
@@ -0,0 +1,20 @@
1
+ export interface SparseConeReport {
2
+ reapplied: string[];
3
+ failed: {
4
+ branch: string;
5
+ error: string;
6
+ }[];
7
+ }
8
+ /**
9
+ * Move each provisioned sparse clone to the cone the deployment's config now asks for, as
10
+ * recorded beside the branch directories by the Lambda (branch-sparse.ts): a changed content
11
+ * root would otherwise leave every existing clone without its content. A full clone stays
12
+ * full. Best-effort per clone, like [SYNC-M2].
13
+ *
14
+ * The cone is compared without a lock, and changed only under the clone's provisioning lock
15
+ * and content-write lock, both zero-retry, outside a rebase: `sparse-checkout set` rewrites the
16
+ * working tree. A clone that is busy, or holds an unpublished deletion, is retried next cycle.
17
+ */
18
+ export declare function reapplySparseCones(ctx: {
19
+ contentBranchesPath: string;
20
+ }): Promise<SparseConeReport>;
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { simpleGit } from 'simple-git';
4
+ import { readRecordedSparseCone, sameCone } from '../branch-sparse.js';
5
+ import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
+ import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
7
+ import { getErrorMessage, isNodeError } from '../utils/error.js';
8
+ import { isRebaseInProgress } from '../utils/git.js';
9
+ import { workerLog, workerLogWarn } from './log.js';
10
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
11
+ /** A clone's cone directories, or null when it is not sparse (or cannot be read). */
12
+ async function currentCone(git) {
13
+ try {
14
+ const listed = (await git.raw(['sparse-checkout', 'list'])).trim();
15
+ return listed ? listed.split('\n') : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /**
22
+ * Move each provisioned sparse clone to the cone the deployment's config now asks for, as
23
+ * recorded beside the branch directories by the Lambda (branch-sparse.ts): a changed content
24
+ * root would otherwise leave every existing clone without its content. A full clone stays
25
+ * full. Best-effort per clone, like [SYNC-M2].
26
+ *
27
+ * The cone is compared without a lock, and changed only under the clone's provisioning lock
28
+ * and content-write lock, both zero-retry, outside a rebase: `sparse-checkout set` rewrites the
29
+ * working tree. A clone that is busy, or holds an unpublished deletion, is retried next cycle.
30
+ */
31
+ export async function reapplySparseCones(ctx) {
32
+ const report = { reapplied: [], failed: [] };
33
+ const recorded = await readRecordedSparseCone(ctx.contentBranchesPath);
34
+ if (!recorded)
35
+ return report;
36
+ let entries;
37
+ try {
38
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
39
+ }
40
+ catch (err) {
41
+ if (isNodeError(err) && err.code === 'ENOENT')
42
+ return report;
43
+ throw err;
44
+ }
45
+ const dirNames = entries
46
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
47
+ .map((entry) => entry.name)
48
+ .sort();
49
+ for (const dirName of dirNames) {
50
+ const branchPath = path.join(ctx.contentBranchesPath, dirName);
51
+ // Without its own .git, git would answer for a repository above the branches root.
52
+ const isClone = await fs.stat(path.join(branchPath, '.git')).then((stat) => stat.isDirectory(), () => false);
53
+ if (!isClone)
54
+ continue;
55
+ const git = simpleGit({ baseDir: branchPath });
56
+ const cone = await currentCone(git);
57
+ if (cone === null || sameCone(cone, recorded.cone))
58
+ continue;
59
+ try {
60
+ const outcome = await reapplyOne(ctx.contentBranchesPath, dirName, git, recorded.cone);
61
+ if (outcome === 'applied') {
62
+ workerLog(` ${dirName}: sparse-checkout cone ${[...cone].sort().join(',')} -> ` +
63
+ (recorded.cone ? [...recorded.cone].sort().join(',') : 'full checkout'));
64
+ report.reapplied.push(dirName);
65
+ }
66
+ else if (typeof outcome === 'number') {
67
+ workerLog(` ${dirName}: sparse-checkout cone waits for ${outcome} unpublished ` +
68
+ `deletion${outcome === 1 ? '' : 's'} to be published`);
69
+ }
70
+ }
71
+ catch (err) {
72
+ const error = getErrorMessage(err);
73
+ workerLogWarn(` ${dirName}: could not change the sparse-checkout cone: ${error}`);
74
+ report.failed.push({ branch: dirName, error });
75
+ }
76
+ }
77
+ return report;
78
+ }
79
+ /**
80
+ * Apply `target` under both locks: `'applied'`, `'skipped'` when the clone is busy or no longer
81
+ * differs, or the count of its unpublished deletions, which hold the cone where it is.
82
+ */
83
+ async function reapplyOne(contentBranchesPath, dirName, git, target) {
84
+ const branchPath = path.join(contentBranchesPath, dirName);
85
+ const hold = await holdProvisionedWorkspace(contentBranchesPath, dirName);
86
+ if (hold.kind !== 'held')
87
+ return 'skipped';
88
+ try {
89
+ let releaseContent;
90
+ let contentLost = false;
91
+ try {
92
+ releaseContent = await tryAcquireContentWriteLock(branchPath, () => {
93
+ contentLost = true;
94
+ });
95
+ }
96
+ catch (err) {
97
+ if (isNodeError(err) && err.code === 'ELOCKED')
98
+ return 'skipped';
99
+ throw err;
100
+ }
101
+ try {
102
+ if (await isRebaseInProgress(branchPath))
103
+ return 'skipped';
104
+ const cone = await currentCone(git);
105
+ if (cone === null || sameCone(cone, target))
106
+ return 'skipped';
107
+ // A tracked file missing from the working tree is the one edit a cone change loses: leaving
108
+ // the cone marks it skip-worktree, so the deletion drops out of `status` and `add -A`, and a
109
+ // wider cone checks the file out again. Modified, untracked and staged changes all survive.
110
+ const deleted = (await git.raw(['ls-files', '--deleted', '-z'])).split('\0').filter(Boolean);
111
+ if (deleted.length > 0)
112
+ return deleted.length;
113
+ if (hold.isCompromised() || contentLost)
114
+ return 'skipped';
115
+ try {
116
+ await git.raw(target
117
+ ? ['sparse-checkout', 'set', '--cone', '--', ...target]
118
+ : ['sparse-checkout', 'disable']);
119
+ }
120
+ finally {
121
+ await invalidateBranchContentCaches(branchPath);
122
+ }
123
+ return 'applied';
124
+ }
125
+ finally {
126
+ await releaseContent().catch(() => { });
127
+ }
128
+ }
129
+ finally {
130
+ await releaseProvisionedWorkspace(hold.release, dirName);
131
+ }
132
+ }
@@ -3,10 +3,10 @@ import { simpleGit } from 'simple-git';
3
3
  import { completeTask, dequeueTask, failTask, recoverOrphanedTasks, retryTask, } from '../task-queue/cms-task-queue.js';
4
4
  import { createOrUpdatePullRequest } from '../github-service.js';
5
5
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
6
- import { sanitizeBranchName } from '../paths/branch-name.js';
6
+ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
7
7
  import { gitNetworkChildEnv } from '../git-manager.js';
8
8
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
9
- import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
9
+ import { isNonFastForwardRejection, isStaleLeaseRejection, workflowPushRefusalFile, } from '../utils/git.js';
10
10
  import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
11
11
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
12
12
  import { workerLog, workerLogError } from './log.js';
@@ -90,6 +90,15 @@ function isRateLimitSignal403(err) {
90
90
  return false;
91
91
  }
92
92
  // Payload validation helpers — fail fast with clear errors instead of silent `as` casts
93
+ /**
94
+ * Whether `branch` carries the reserved settings-branch prefix. Matching on the prefix
95
+ * alone, never on a configured name, is what keeps a content branch out: branch creation
96
+ * rejects the prefix, while a configured name the worker and API disagree on could be a
97
+ * content branch's.
98
+ */
99
+ function isSettingsBranch(branch) {
100
+ return branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
101
+ }
93
102
  function requireString(payload, key) {
94
103
  const val = payload[key];
95
104
  if (typeof val !== 'string')
@@ -282,6 +291,13 @@ export async function executeTask(ctx, task, signal) {
282
291
  if (sanitizeBranchName(branch) === ctx.sanitizedBaseBranch) {
283
292
  throw new PermanentTaskError(`Refusing to push-and-create-or-update-pr for "${branch}": it is the base branch -- submitting the base branch is never valid`);
284
293
  }
294
+ // The settings branch is an orphan with no history in common with the
295
+ // base, so GitHub 422s a PR for it: push it and stop.
296
+ if (isSettingsBranch(branch)) {
297
+ await ctx.pushBranchToGitHub(branch);
298
+ workerLog(`Pushed settings branch ${branch}; settings branches never get a PR`);
299
+ return { pushed: true };
300
+ }
285
301
  await ctx.pushBranchToGitHub(branch);
286
302
  const result = await createOrUpdatePullRequest({
287
303
  octokit: ctx.octokit(),
@@ -291,9 +307,9 @@ export async function executeTask(ctx, task, signal) {
291
307
  base,
292
308
  title: optionalString(payload, 'title', `Submit ${branch}`),
293
309
  body: optionalString(payload, 'body', ''),
294
- // Content submits (api/github-sync.ts) set this; settings-branch
295
- // syncs (services.ts) deliberately don't.
310
+ // Content submits (api/github-sync.ts) set both.
296
311
  markReadyIfDraft: payload.markReadyIfDraft === true,
312
+ mergeSectionIntoBody: payload.mergeSectionIntoBody === true,
297
313
  signal,
298
314
  });
299
315
  workerLog(result.created
@@ -415,6 +431,23 @@ async function updateBranchMetadataOnFailure(ctx, task, error) {
415
431
  workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
416
432
  }
417
433
  }
434
+ /**
435
+ * Fail fast when GitHub refused the push for adding workflow content it does not already hold: the
436
+ * worker's credential deliberately lacks the workflows permission, so the identical push can never
437
+ * succeed. Rebasing onto a base that changed a workflow does not trigger it; a workflow edit made
438
+ * outside the editor, auto-merged by the rebase with a base change to the same file, does.
439
+ */
440
+ function throwIfWorkflowRefusal(branch, message) {
441
+ const file = workflowPushRefusalFile(message);
442
+ if (file === null)
443
+ return;
444
+ throw new PermanentTaskError(`Push refused for branch "${branch}": it would put a version of ${file} on GitHub that ` +
445
+ `GitHub does not already have, and this deployment's GitHub credential is deliberately not ` +
446
+ `allowed to change workflow files. Such a change usually comes from outside the editor, ` +
447
+ `such as a direct push to this branch. Nothing was pushed, and ` +
448
+ `retrying will not help until a developer with permission to change workflow files ` +
449
+ `resolves it on GitHub.`);
450
+ }
418
451
  export async function pushBranchToGitHub(ctx, branch) {
419
452
  const git = simpleGit({
420
453
  baseDir: ctx.remoteGitPath,
@@ -473,6 +506,7 @@ export async function pushBranchToGitHub(ctx, branch) {
473
506
  }
474
507
  catch (err) {
475
508
  const message = getErrorMessage(err);
509
+ throwIfWorkflowRefusal(branch, message);
476
510
  // A refused lease means GitHub is not at the commit we rewrote, so the
477
511
  // marker is stale -- routine, not exceptional: tasks are re-run after a
478
512
  // crash (recoverOrphanedTasks) and the marker survives any failure to
@@ -494,6 +528,7 @@ export async function pushBranchToGitHub(ctx, branch) {
494
528
  }
495
529
  catch (retryErr) {
496
530
  const retryMessage = getErrorMessage(retryErr);
531
+ throwIfWorkflowRefusal(branch, retryMessage);
497
532
  if (isNonFastForwardRejection(retryMessage)) {
498
533
  throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
499
534
  `deployment last published nor an ancestor of what it is pushing, so the branch ` +
@@ -38,7 +38,12 @@ export interface WorkerContext {
38
38
  readonly sanitizedBaseBranch: SanitizedBranchName;
39
39
  /** `{workspacePath}/.tasks` — the task queue and worker-status.json. */
40
40
  readonly taskDir: string;
41
- /** `{workspacePath}/remote.git` — the shared bare repo. */
41
+ /**
42
+ * `{workspacePath}/remote.git` — the shared bare repo. The worker fetches
43
+ * into and pushes from a workspace clone against this path, never the
44
+ * clone's `origin`: that holds the path the CLONING process saw (usually
45
+ * the Lambda), and the clone's config is Lambda-writable.
46
+ */
42
47
  readonly remoteGitPath: string;
43
48
  /** `{workspacePath}/content-branches` — the branch workspace root. */
44
49
  readonly contentBranchesPath: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.67",
4
+ "version": "0.0.68-int.100",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -87,6 +87,7 @@
87
87
  "dependencies": {
88
88
  "@aws-sdk/client-s3": "^3.600.0",
89
89
  "@aws-sdk/s3-presigned-post": "^3.600.0",
90
+ "@aws-sdk/s3-request-presigner": "^3.600.0",
90
91
  "@clack/prompts": "^1.1.0",
91
92
  "@codemirror/language": "^6.0.0",
92
93
  "@dnd-kit/core": "^6.3.1",