canopycms 0.0.61-int.75 → 0.0.61

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 (271) hide show
  1. package/dist/ai/handler.js +1 -1
  2. package/dist/ai/handler.js.map +1 -1
  3. package/dist/api/assets.d.ts.map +1 -1
  4. package/dist/api/assets.js +36 -4
  5. package/dist/api/assets.js.map +1 -1
  6. package/dist/api/branch-status.d.ts.map +1 -1
  7. package/dist/api/branch-status.js +56 -0
  8. package/dist/api/branch-status.js.map +1 -1
  9. package/dist/api/branch-withdraw.d.ts.map +1 -1
  10. package/dist/api/branch-withdraw.js +14 -3
  11. package/dist/api/branch-withdraw.js.map +1 -1
  12. package/dist/api/branch.d.ts +31 -2
  13. package/dist/api/branch.d.ts.map +1 -1
  14. package/dist/api/branch.js +252 -13
  15. package/dist/api/branch.js.map +1 -1
  16. package/dist/api/content.d.ts.map +1 -1
  17. package/dist/api/content.js +12 -4
  18. package/dist/api/content.js.map +1 -1
  19. package/dist/api/entries.d.ts.map +1 -1
  20. package/dist/api/entries.js +11 -9
  21. package/dist/api/entries.js.map +1 -1
  22. package/dist/api/guards.d.ts.map +1 -1
  23. package/dist/api/guards.js +12 -5
  24. package/dist/api/guards.js.map +1 -1
  25. package/dist/api/permissions.d.ts +2 -2
  26. package/dist/api/permissions.d.ts.map +1 -1
  27. package/dist/api/permissions.js +51 -3
  28. package/dist/api/permissions.js.map +1 -1
  29. package/dist/api/reference-options.d.ts.map +1 -1
  30. package/dist/api/reference-options.js +10 -17
  31. package/dist/api/reference-options.js.map +1 -1
  32. package/dist/api/resolve-references.d.ts.map +1 -1
  33. package/dist/api/resolve-references.js +3 -1
  34. package/dist/api/resolve-references.js.map +1 -1
  35. package/dist/api/schema.d.ts.map +1 -1
  36. package/dist/api/schema.js +3 -2
  37. package/dist/api/schema.js.map +1 -1
  38. package/dist/api/settings-helpers.d.ts.map +1 -1
  39. package/dist/api/settings-helpers.js +6 -5
  40. package/dist/api/settings-helpers.js.map +1 -1
  41. package/dist/assets/pipeline.d.ts +11 -2
  42. package/dist/assets/pipeline.d.ts.map +1 -1
  43. package/dist/assets/pipeline.js +114 -2
  44. package/dist/assets/pipeline.js.map +1 -1
  45. package/dist/assets/transform-directives.d.ts +18 -0
  46. package/dist/assets/transform-directives.d.ts.map +1 -1
  47. package/dist/assets/transform-directives.js +18 -0
  48. package/dist/assets/transform-directives.js.map +1 -1
  49. package/dist/assets/transform.d.ts.map +1 -1
  50. package/dist/assets/transform.js +1 -10
  51. package/dist/assets/transform.js.map +1 -1
  52. package/dist/auth/types.d.ts +29 -0
  53. package/dist/auth/types.d.ts.map +1 -1
  54. package/dist/authorization/index.d.ts +1 -1
  55. package/dist/authorization/index.d.ts.map +1 -1
  56. package/dist/authorization/index.js +1 -1
  57. package/dist/authorization/index.js.map +1 -1
  58. package/dist/authorization/protected-branch.d.ts +75 -0
  59. package/dist/authorization/protected-branch.d.ts.map +1 -1
  60. package/dist/authorization/protected-branch.js +39 -0
  61. package/dist/authorization/protected-branch.js.map +1 -1
  62. package/dist/branch-registry.d.ts.map +1 -1
  63. package/dist/branch-registry.js +5 -1
  64. package/dist/branch-registry.js.map +1 -1
  65. package/dist/branch-schema-cache.d.ts.map +1 -1
  66. package/dist/branch-schema-cache.js +9 -0
  67. package/dist/branch-schema-cache.js.map +1 -1
  68. package/dist/build/generate-ai-content.js +1 -1
  69. package/dist/build/generate-ai-content.js.map +1 -1
  70. package/dist/cli/cli.js +1442 -828
  71. package/dist/cli/generate-ai-content.js +404 -136
  72. package/dist/cli/init.d.ts +8 -2
  73. package/dist/cli/init.d.ts.map +1 -1
  74. package/dist/cli/init.js +440 -151
  75. package/dist/cli/init.js.map +1 -1
  76. package/dist/cli/project-detect.d.ts +89 -0
  77. package/dist/cli/project-detect.d.ts.map +1 -0
  78. package/dist/cli/project-detect.js +240 -0
  79. package/dist/cli/project-detect.js.map +1 -0
  80. package/dist/cli/template-files/Dockerfile.cms.template +11 -6
  81. package/dist/cli/template-files/cdk-app.ts.template +82 -0
  82. package/dist/cli/template-files/cdk.json.template +9 -0
  83. package/dist/cli/template-files/cms-stack.ts.template +124 -0
  84. package/dist/cli/template-files/deploy-cms.yml.template +125 -13
  85. package/dist/cli/template-files/dockerignore.template +2 -1
  86. package/dist/cli/templates.d.ts +26 -2
  87. package/dist/cli/templates.d.ts.map +1 -1
  88. package/dist/cli/templates.js +36 -4
  89. package/dist/cli/templates.js.map +1 -1
  90. package/dist/config/schemas/config.d.ts +2 -3
  91. package/dist/config/schemas/config.d.ts.map +1 -1
  92. package/dist/config/schemas/config.js +21 -1
  93. package/dist/config/schemas/config.js.map +1 -1
  94. package/dist/config/validation.d.ts +9 -0
  95. package/dist/config/validation.d.ts.map +1 -1
  96. package/dist/config/validation.js +22 -12
  97. package/dist/config/validation.js.map +1 -1
  98. package/dist/content-reader.js +1 -1
  99. package/dist/content-reader.js.map +1 -1
  100. package/dist/content-store.d.ts +11 -0
  101. package/dist/content-store.d.ts.map +1 -1
  102. package/dist/content-store.js +2 -1
  103. package/dist/content-store.js.map +1 -1
  104. package/dist/dev-content-watcher.d.ts.map +1 -1
  105. package/dist/dev-content-watcher.js +4 -1
  106. package/dist/dev-content-watcher.js.map +1 -1
  107. package/dist/editor/BranchManager.d.ts +15 -0
  108. package/dist/editor/BranchManager.d.ts.map +1 -1
  109. package/dist/editor/BranchManager.js +43 -9
  110. package/dist/editor/BranchManager.js.map +1 -1
  111. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -2
  113. package/dist/editor/CanopyEditor.js.map +1 -1
  114. package/dist/editor/Editor.d.ts.map +1 -1
  115. package/dist/editor/Editor.js +110 -48
  116. package/dist/editor/Editor.js.map +1 -1
  117. package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -1
  118. package/dist/editor/admin/SystemHealthPanel.js +9 -8
  119. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  120. package/dist/editor/components/EditorHeader.d.ts +6 -0
  121. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  122. package/dist/editor/components/EditorHeader.js +76 -23
  123. package/dist/editor/components/EditorHeader.js.map +1 -1
  124. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  125. package/dist/editor/components/EntryCreateModal.js +21 -6
  126. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  127. package/dist/editor/context/SWRProvider.d.ts +31 -0
  128. package/dist/editor/context/SWRProvider.d.ts.map +1 -0
  129. package/dist/editor/context/SWRProvider.js +12 -0
  130. package/dist/editor/context/SWRProvider.js.map +1 -0
  131. package/dist/editor/context/index.d.ts +3 -0
  132. package/dist/editor/context/index.d.ts.map +1 -1
  133. package/dist/editor/context/index.js +3 -0
  134. package/dist/editor/context/index.js.map +1 -1
  135. package/dist/editor/editor-utils.d.ts.map +1 -1
  136. package/dist/editor/editor-utils.js +8 -2
  137. package/dist/editor/editor-utils.js.map +1 -1
  138. package/dist/editor/fields/ImageField.d.ts.map +1 -1
  139. package/dist/editor/fields/ImageField.js +27 -3
  140. package/dist/editor/fields/ImageField.js.map +1 -1
  141. package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
  142. package/dist/editor/fields/ReferenceField.js +52 -31
  143. package/dist/editor/fields/ReferenceField.js.map +1 -1
  144. package/dist/editor/hooks/__test__/test-utils.d.ts +38 -3
  145. package/dist/editor/hooks/__test__/test-utils.d.ts.map +1 -1
  146. package/dist/editor/hooks/__test__/test-utils.js +50 -4
  147. package/dist/editor/hooks/__test__/test-utils.js.map +1 -1
  148. package/dist/editor/hooks/index.d.ts +4 -1
  149. package/dist/editor/hooks/index.d.ts.map +1 -1
  150. package/dist/editor/hooks/index.js +4 -1
  151. package/dist/editor/hooks/index.js.map +1 -1
  152. package/dist/editor/hooks/useBranchManager.d.ts +21 -1
  153. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  154. package/dist/editor/hooks/useBranchManager.js +68 -32
  155. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  156. package/dist/editor/hooks/useBranchesData.d.ts +57 -0
  157. package/dist/editor/hooks/useBranchesData.d.ts.map +1 -0
  158. package/dist/editor/hooks/useBranchesData.js +69 -0
  159. package/dist/editor/hooks/useBranchesData.js.map +1 -0
  160. package/dist/editor/hooks/useCommentSystem.d.ts.map +1 -1
  161. package/dist/editor/hooks/useCommentSystem.js +24 -22
  162. package/dist/editor/hooks/useCommentSystem.js.map +1 -1
  163. package/dist/editor/hooks/useCommentsData.d.ts +29 -0
  164. package/dist/editor/hooks/useCommentsData.d.ts.map +1 -0
  165. package/dist/editor/hooks/useCommentsData.js +40 -0
  166. package/dist/editor/hooks/useCommentsData.js.map +1 -0
  167. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  168. package/dist/editor/hooks/useDraftManager.js +32 -18
  169. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  170. package/dist/editor/hooks/useEntriesData.d.ts +35 -0
  171. package/dist/editor/hooks/useEntriesData.d.ts.map +1 -0
  172. package/dist/editor/hooks/useEntriesData.js +96 -0
  173. package/dist/editor/hooks/useEntriesData.js.map +1 -0
  174. package/dist/editor/hooks/useEntryManager.d.ts +8 -15
  175. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  176. package/dist/editor/hooks/useEntryManager.js +259 -117
  177. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  178. package/dist/editor/hooks/usePermissionManager.d.ts +2 -2
  179. package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
  180. package/dist/editor/hooks/usePermissionManager.js +11 -9
  181. package/dist/editor/hooks/usePermissionManager.js.map +1 -1
  182. package/dist/editor/hooks/useReferenceResolution.d.ts.map +1 -1
  183. package/dist/editor/hooks/useReferenceResolution.js +24 -1
  184. package/dist/editor/hooks/useReferenceResolution.js.map +1 -1
  185. package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -1
  186. package/dist/editor/media/MediaLibraryBody.js +12 -2
  187. package/dist/editor/media/MediaLibraryBody.js.map +1 -1
  188. package/dist/editor/permission-manager/GroupSelector.d.ts.map +1 -1
  189. package/dist/editor/permission-manager/GroupSelector.js +7 -2
  190. package/dist/editor/permission-manager/GroupSelector.js.map +1 -1
  191. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +3 -3
  192. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts.map +1 -1
  193. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +1 -0
  194. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js.map +1 -1
  195. package/dist/editor/permission-manager/types.d.ts +6 -4
  196. package/dist/editor/permission-manager/types.d.ts.map +1 -1
  197. package/dist/editor/test-setup.js +8 -0
  198. package/dist/editor/test-setup.js.map +1 -1
  199. package/dist/git-manager.d.ts +126 -9
  200. package/dist/git-manager.d.ts.map +1 -1
  201. package/dist/git-manager.js +251 -27
  202. package/dist/git-manager.js.map +1 -1
  203. package/dist/github-service.d.ts.map +1 -1
  204. package/dist/github-service.js +13 -7
  205. package/dist/github-service.js.map +1 -1
  206. package/dist/http/handler.d.ts.map +1 -1
  207. package/dist/http/handler.js +8 -2
  208. package/dist/http/handler.js.map +1 -1
  209. package/dist/operating-mode/client-unsafe-strategy.d.ts.map +1 -1
  210. package/dist/operating-mode/client-unsafe-strategy.js +7 -8
  211. package/dist/operating-mode/client-unsafe-strategy.js.map +1 -1
  212. package/dist/operating-mode/deployment-name.d.ts +28 -0
  213. package/dist/operating-mode/deployment-name.d.ts.map +1 -0
  214. package/dist/operating-mode/deployment-name.js +78 -0
  215. package/dist/operating-mode/deployment-name.js.map +1 -0
  216. package/dist/operating-mode/index.d.ts +5 -1
  217. package/dist/operating-mode/index.d.ts.map +1 -1
  218. package/dist/operating-mode/index.js +7 -1
  219. package/dist/operating-mode/index.js.map +1 -1
  220. package/dist/operating-mode/types.d.ts +13 -3
  221. package/dist/operating-mode/types.d.ts.map +1 -1
  222. package/dist/paths/branch-name.d.ts +38 -0
  223. package/dist/paths/branch-name.d.ts.map +1 -1
  224. package/dist/paths/branch-name.js +46 -0
  225. package/dist/paths/branch-name.js.map +1 -1
  226. package/dist/paths/index.d.ts +1 -1
  227. package/dist/paths/index.d.ts.map +1 -1
  228. package/dist/paths/index.js +1 -1
  229. package/dist/paths/index.js.map +1 -1
  230. package/dist/reference-resolver.d.ts +6 -2
  231. package/dist/reference-resolver.d.ts.map +1 -1
  232. package/dist/reference-resolver.js +8 -1
  233. package/dist/reference-resolver.js.map +1 -1
  234. package/dist/schema/meta-loader.d.ts.map +1 -1
  235. package/dist/schema/meta-loader.js +10 -2
  236. package/dist/schema/meta-loader.js.map +1 -1
  237. package/dist/schema/schema-store.d.ts +11 -2
  238. package/dist/schema/schema-store.d.ts.map +1 -1
  239. package/dist/schema/schema-store.js +23 -11
  240. package/dist/schema/schema-store.js.map +1 -1
  241. package/dist/settings-workspace.d.ts +0 -15
  242. package/dist/settings-workspace.d.ts.map +1 -1
  243. package/dist/settings-workspace.js +97 -1
  244. package/dist/settings-workspace.js.map +1 -1
  245. package/dist/types.d.ts +63 -2
  246. package/dist/types.d.ts.map +1 -1
  247. package/dist/utils/debug.d.ts +11 -0
  248. package/dist/utils/debug.d.ts.map +1 -1
  249. package/dist/utils/debug.js +12 -1
  250. package/dist/utils/debug.js.map +1 -1
  251. package/dist/utils/git.d.ts +38 -0
  252. package/dist/utils/git.d.ts.map +1 -1
  253. package/dist/utils/git.js +69 -0
  254. package/dist/utils/git.js.map +1 -1
  255. package/dist/utils/logger.d.ts +71 -0
  256. package/dist/utils/logger.d.ts.map +1 -0
  257. package/dist/utils/logger.js +85 -0
  258. package/dist/utils/logger.js.map +1 -0
  259. package/dist/validation/entry-type-reference-validator.d.ts +33 -0
  260. package/dist/validation/entry-type-reference-validator.d.ts.map +1 -0
  261. package/dist/validation/entry-type-reference-validator.js +110 -0
  262. package/dist/validation/entry-type-reference-validator.js.map +1 -0
  263. package/dist/worker/cms-worker.d.ts +232 -3
  264. package/dist/worker/cms-worker.d.ts.map +1 -1
  265. package/dist/worker/cms-worker.js +856 -87
  266. package/dist/worker/cms-worker.js.map +1 -1
  267. package/dist/worker/log.d.ts +61 -0
  268. package/dist/worker/log.d.ts.map +1 -0
  269. package/dist/worker/log.js +74 -0
  270. package/dist/worker/log.js.map +1 -0
  271. package/package.json +3 -1
@@ -2,15 +2,26 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { simpleGit } from 'simple-git';
4
4
  import lockfile from 'proper-lockfile';
5
- import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
5
+ import { enqueueTask, listTasks, dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
6
6
  import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
7
7
  import { getBranchMetadataFileManager, BranchMetadataFileManager, buildMergedBranchUpdate, } from '../branch-metadata.js';
8
8
  import { extractIdFromFilename } from '../content-id-index.js';
9
9
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
10
10
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
11
- import { sanitizeBranchName } from '../paths/branch-name.js';
11
+ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
12
+ import { normalizeFilesystemPath } from '../paths/normalize.js';
13
+ import { GITHUB_TRACKING_REF_PREFIX, gitChildEnv, gitNetworkChildEnv } from '../git-manager.js';
14
+ import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
12
15
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
16
+ import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
13
17
  import { writeWorkerStatus } from './worker-status.js';
18
+ import { workerLog, workerLogWarn, workerLogError } from './log.js';
19
+ // Re-exported so the AWS entrypoint (packages/canopycms-cdk/worker/index.ts)
20
+ // can prefix its own startup lines through the same helpers without adding a
21
+ // new package entrypoint - `canopycms/worker/cms-worker` already exists. Every
22
+ // line in worker.log must carry the timestamp prefix or it gets folded into
23
+ // the previous CloudWatch event; see ./log.ts.
24
+ export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from './log.js';
14
25
  const DEFAULT_TASK_TIMEOUT = 60_000;
15
26
  const DEFAULT_MAX_RETRIES = 3;
16
27
  const DEFAULT_LOCK_STALE_MS = 60_000;
@@ -183,9 +194,45 @@ export class CmsWorker {
183
194
  }
184
195
  return this.statusReport;
185
196
  }
197
+ /**
198
+ * Resolve this deployment's settings branch, throwing if the infra-stamped
199
+ * deployment name is not a valid git ref component.
200
+ *
201
+ * Routed through the shared resolver, not a local `?? 'prod'`: it is the
202
+ * single definition of the env > config > mode-default precedence the Lambda
203
+ * already follows, and the only place the resolved value is validated.
204
+ * Without it the worker could silently own a different settings branch than
205
+ * the Lambda writing to the same workspace -- and pushSettingsBranches would
206
+ * then report the real branch as foreign and never push it.
207
+ *
208
+ * DEFERRED out of the constructor deliberately, and this is the whole point
209
+ * of the method existing. Resolving there made the throw land during `new
210
+ * CmsWorker(...)` (canopycms-cdk/worker/index.ts constructs before it calls
211
+ * start()), which is BEFORE the only code that writes `lastFatalError` --
212
+ * start()'s catch. The result was billed as "a loud startup exit" but was
213
+ * nothing of the kind: with systemd `Type=simple` + `Restart=always` and no
214
+ * cfn-signal, an invalid deployment name produced an invisible ~5s
215
+ * crash-loop that `cdk deploy` reported as success while the admin panel
216
+ * showed the worker as 'absent' with no fatal error to explain it. Resolving
217
+ * inside start()'s try block instead means the same throw is recorded to
218
+ * worker-status.json and surfaces in the admin panel.
219
+ *
220
+ * Lazy rather than start()-only so the value is still available to unit tests
221
+ * that drive pushSettingsBranches() directly without calling start() -- the
222
+ * same reason ensureStatusReport() above is shaped this way. Idempotent: the
223
+ * resolver is pure, so a later call returns the identical string.
224
+ */
225
+ ensureSettingsBranch() {
226
+ if (this.settingsBranchResolved === undefined) {
227
+ this.settingsBranchResolved =
228
+ this.config.settingsBranch ??
229
+ `${RESERVED_SETTINGS_BRANCH_PREFIX}${resolveDeploymentName({ deploymentName: this.config.deploymentName }, 'prod')}`;
230
+ }
231
+ return this.settingsBranchResolved;
232
+ }
186
233
  async start() {
187
234
  this.running = true;
188
- console.log('CMS Worker starting...');
235
+ workerLog('CMS Worker starting...');
189
236
  this.ensureStatusReport();
190
237
  // Acquire lock to prevent concurrent workers
191
238
  await this.acquireLock();
@@ -202,12 +249,24 @@ export class CmsWorker {
202
249
  // clones elsewhere) — acquiring the lock first is what already
203
250
  // serializes that.
204
251
  try {
252
+ // FIRST inside the try, before any I/O: an infra-stamped deployment name
253
+ // that is not a valid git ref component throws here, where the catch
254
+ // below records it to worker-status.json. Resolving it in the
255
+ // constructor (as this used to) put the throw outside every
256
+ // status-writing path -- see ensureSettingsBranch()'s doc comment.
257
+ this.ensureSettingsBranch();
205
258
  // Ensure remote.git exists (init bare repo if first run)
206
259
  await this.ensureRemoteGit();
207
- // Recover any orphaned tasks from a previous crash
208
- const recovered = await recoverOrphanedTasks(this.taskDir, undefined, this.log);
260
+ // Recover any orphaned tasks from a previous crash, immediately rather
261
+ // than waiting for the first processTaskQueue() poll (taskPollInterval,
262
+ // default 5s) to run it. Not the only call site any more -
263
+ // processTaskQueue() below now repeats this on every cycle; see its
264
+ // doc comment for why a boot-only call is insufficient once the
265
+ // worker's ASG rolls on every `cdk deploy` (CanopyCmsService's
266
+ // UpdatePolicy).
267
+ const recovered = await recoverOrphanedTasks(this.taskDir, this.orphanRecoveryMaxAgeMs(), this.log);
209
268
  if (recovered > 0) {
210
- console.log(`Recovered ${recovered} orphaned task(s)`);
269
+ workerLog(`Recovered ${recovered} orphaned task(s)`);
211
270
  }
212
271
  // Run initial sync + cache refresh immediately
213
272
  const initialTasks = [this.syncGit()];
@@ -234,7 +293,7 @@ export class CmsWorker {
234
293
  await writeWorkerStatus(this.taskDir, report);
235
294
  }
236
295
  catch (writeErr) {
237
- console.error('Failed to write worker status on startup failure:', getErrorMessage(writeErr));
296
+ workerLogError('Failed to write worker status on startup failure:', getErrorMessage(writeErr));
238
297
  }
239
298
  await this.releaseLock();
240
299
  throw err;
@@ -248,11 +307,11 @@ export class CmsWorker {
248
307
  if (this.config.refreshAuthCache) {
249
308
  const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
250
309
  this.scheduleLoop(() => this.refreshAuthCache(), cacheInterval);
251
- console.log(` Auth cache refresh: every ${cacheInterval / 1000}s`);
310
+ workerLog(` Auth cache refresh: every ${cacheInterval / 1000}s`);
252
311
  }
253
- console.log('CMS Worker started');
254
- console.log(` Task queue poll: every ${taskInterval / 1000}s`);
255
- console.log(` Git sync: every ${gitInterval / 1000}s`);
312
+ workerLog('CMS Worker started');
313
+ workerLog(` Task queue poll: every ${taskInterval / 1000}s`);
314
+ workerLog(` Git sync: every ${gitInterval / 1000}s`);
256
315
  }
257
316
  async stop() {
258
317
  this.running = false;
@@ -270,7 +329,7 @@ export class CmsWorker {
270
329
  ]);
271
330
  clearTimeout(drainTimer);
272
331
  await this.releaseLock();
273
- console.log('CMS Worker stopped');
332
+ workerLog('CMS Worker stopped');
274
333
  }
275
334
  /**
276
335
  * Acquire the cross-host worker lock (DEP-C2).
@@ -307,7 +366,7 @@ export class CmsWorker {
307
366
  // Our heartbeat could not be maintained (lock deleted or taken
308
367
  // over). Another worker may now be consuming the queue — stop
309
368
  // processing to preserve the single-consumer invariant.
310
- console.error('Worker lock compromised, shutting down:', getErrorMessage(err));
369
+ workerLogError('Worker lock compromised, shutting down:', getErrorMessage(err));
311
370
  this.releaseLockFn = null; // the lock is already lost; nothing to release
312
371
  void this.stop();
313
372
  },
@@ -344,7 +403,7 @@ export class CmsWorker {
344
403
  const timeout = setTimeout(async () => {
345
404
  this.activeTimeouts.delete(timeout);
346
405
  const operation = fn().catch((err) => {
347
- console.error('Worker loop error:', err instanceof Error ? err.message : err);
406
+ workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
348
407
  });
349
408
  this.activeOperations.add(operation);
350
409
  operation.finally(() => this.activeOperations.delete(operation));
@@ -409,7 +468,7 @@ export class CmsWorker {
409
468
  await this.verifyBaseBranchExists(this.remoteGitPath);
410
469
  }
411
470
  catch (err) {
412
- console.error(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
471
+ workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
413
472
  // Do NOT auto-delete: an existing remote.git could hold unpushed
414
473
  // canopycms-settings-* branches or other state worth preserving.
415
474
  // Deletion here is the operator's call, not ours.
@@ -417,14 +476,14 @@ export class CmsWorker {
417
476
  }
418
477
  return; // Already exists and has the base branch
419
478
  }
420
- console.log('Initializing remote.git from GitHub...');
479
+ workerLog('Initializing remote.git from GitHub...');
421
480
  const git = simpleGit();
422
481
  await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
423
482
  try {
424
483
  await this.verifyBaseBranchExists(this.remoteGitPath);
425
484
  }
426
485
  catch (err) {
427
- console.error(`remote.git base branch verification failed after clone: ${getErrorMessage(err)}`);
486
+ workerLogError(`remote.git base branch verification failed after clone: ${getErrorMessage(err)}`);
428
487
  // Deleting before throwing is what makes this recoverable: the next
429
488
  // start() sees no remote.git and re-clones, instead of being stuck
430
489
  // forever behind a poisoned bare repo that fs.stat alone can't detect.
@@ -434,7 +493,20 @@ export class CmsWorker {
434
493
  // Remove the origin remote so the token doesn't persist in config
435
494
  const bareGit = simpleGit({ baseDir: this.remoteGitPath });
436
495
  await bareGit.removeRemote('origin').catch(() => { });
437
- console.log('remote.git initialized');
496
+ workerLog('remote.git initialized');
497
+ }
498
+ /**
499
+ * Staleness threshold for recoverOrphanedTasks, derived from the
500
+ * configured task timeout rather than fixed: the safety argument for
501
+ * running recovery on every poll cycle is "no legitimately in-flight task
502
+ * can be this old, because executeTaskWithTimeout bounds every attempt by
503
+ * taskTimeoutMs" -- which is only true if this threshold scales with
504
+ * taskTimeoutMs. 2x leaves the same comfortable margin the defaults have
505
+ * (60s timeout vs 5min threshold); the 5-minute floor preserves the
506
+ * long-standing default for a replacement instance's boot window.
507
+ */
508
+ orphanRecoveryMaxAgeMs() {
509
+ return Math.max(5 * 60_000, this.taskTimeoutMs * 2);
438
510
  }
439
511
  /**
440
512
  * Process queued tasks from Lambda.
@@ -445,6 +517,32 @@ export class CmsWorker {
445
517
  async processTaskQueue() {
446
518
  if (!this.running)
447
519
  return;
520
+ // Recover tasks orphaned in processing/ on EVERY cycle, not only at boot
521
+ // (start()'s call above). Before this fix, recoverOrphanedTasks() ran
522
+ // exactly once, at start() - fine when an instance was replaced rarely
523
+ // (a spot interruption), but CanopyCmsService's worker ASG now rolls on
524
+ // every `cdk deploy` (see its UpdatePolicy), making replacement routine.
525
+ // A replacement instance's user-data (yum install git/unzip/nodejs/
526
+ // efs-utils, mount EFS) takes roughly 2-4 minutes, well under
527
+ // recoverOrphanedTasks()'s 5-minute default staleness threshold - so
528
+ // THAT ONE boot-time call would see the just-orphaned file as "too
529
+ // fresh" and skip it, and nothing rescanned afterward: the task (and its
530
+ // branch's syncStatus) would be wedged forever. Re-checking every poll
531
+ // means the file's age eventually crosses the threshold and it gets
532
+ // recovered without operator intervention.
533
+ //
534
+ // Safe to run this often: executeTaskWithTimeout() guarantees every task
535
+ // THIS process dequeues is completed, failed, or retried (all three
536
+ // remove the processing/ file) within taskTimeoutMs - and the staleness
537
+ // threshold is derived from taskTimeoutMs (orphanRecoveryMaxAgeMs below)
538
+ // precisely so that guarantee holds for ANY configured timeout, not just
539
+ // the 60s default: a fixed 5-minute threshold would have this call steal
540
+ // the worker's own still-in-flight task back to pending whenever an
541
+ // adopter configured taskTimeoutMs above ~5 minutes.
542
+ const recovered = await recoverOrphanedTasks(this.taskDir, this.orphanRecoveryMaxAgeMs(), this.log);
543
+ if (recovered > 0) {
544
+ workerLog(`Recovered ${recovered} orphaned task(s)`);
545
+ }
448
546
  let processed = 0;
449
547
  let task;
450
548
  while (processed < this.maxTasksPerCycle &&
@@ -456,7 +554,7 @@ export class CmsWorker {
456
554
  }
457
555
  catch (err) {
458
556
  const message = getErrorMessage(err);
459
- console.error(`Task ${task.id} (${task.action}) failed:`, message);
557
+ workerLogError(`Task ${task.id} (${task.action}) failed:`, message);
460
558
  // [HIGH-1] task.error is persisted (pending/failed task JSON) and
461
559
  // served to the browser by the admin panel's Tasks tab -- a push
462
560
  // failure's message can embed the bot token via buildGitHubUrl().
@@ -470,12 +568,12 @@ export class CmsWorker {
470
568
  const maxRetries = task.maxRetries ?? this.maxRetries;
471
569
  if (!permanent && retryCount < maxRetries) {
472
570
  await retryTask(this.taskDir, task.id, persistedMessage, this.log);
473
- console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
571
+ workerLog(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
474
572
  }
475
573
  else {
476
574
  await failTask(this.taskDir, task.id, persistedMessage, this.log);
477
575
  await this.updateBranchMetadataOnFailure(task, persistedMessage);
478
- console.error(permanent
576
+ workerLogError(permanent
479
577
  ? ' Permanently failed (non-retryable error)'
480
578
  : ` Permanently failed after ${maxRetries} retries`);
481
579
  }
@@ -489,7 +587,7 @@ export class CmsWorker {
489
587
  if (processed > 0) {
490
588
  const report = this.ensureStatusReport();
491
589
  report.lastTaskCycleAt = new Date().toISOString();
492
- await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
590
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
493
591
  }
494
592
  }
495
593
  /**
@@ -538,7 +636,7 @@ export class CmsWorker {
538
636
  body: optionalString(payload, 'body', ''),
539
637
  request: { signal },
540
638
  });
541
- console.log(`Created PR #${pr.data.number} for ${branch}`);
639
+ workerLog(`Created PR #${pr.data.number} for ${branch}`);
542
640
  return { prUrl: pr.data.html_url, prNumber: pr.data.number };
543
641
  }
544
642
  case 'push-and-update-pr': {
@@ -553,7 +651,7 @@ export class CmsWorker {
553
651
  body: optionalString(payload, 'body', ''),
554
652
  request: { signal },
555
653
  });
556
- console.log(`Updated PR #${prNumber} for ${branch}`);
654
+ workerLog(`Updated PR #${prNumber} for ${branch}`);
557
655
  return { prNumber };
558
656
  }
559
657
  case 'push-and-create-or-update-pr': {
@@ -590,7 +688,7 @@ export class CmsWorker {
590
688
  markReadyIfDraft: payload.markReadyIfDraft === true,
591
689
  signal,
592
690
  });
593
- console.log(result.created
691
+ workerLog(result.created
594
692
  ? `Created PR #${result.number} for ${branch}`
595
693
  : `Updated existing PR #${result.number} for ${branch}`);
596
694
  return { prUrl: result.url, prNumber: result.number };
@@ -606,7 +704,7 @@ export class CmsWorker {
606
704
  request: { signal },
607
705
  });
608
706
  await this.octokit.graphql(`mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: pr.node_id, request: { signal } });
609
- console.log(`Converted PR #${draftPrNumber} to draft`);
707
+ workerLog(`Converted PR #${draftPrNumber} to draft`);
610
708
  return { prNumber: draftPrNumber, draft: true };
611
709
  }
612
710
  case 'close-pr': {
@@ -642,7 +740,7 @@ export class CmsWorker {
642
740
  const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
643
741
  if (!branch)
644
742
  return;
645
- const branchPath = path.join(this.contentBranchesPath, branch);
743
+ const branchPath = this.branchWorkspacePath(branch);
646
744
  try {
647
745
  await fs.stat(branchPath);
648
746
  }
@@ -654,6 +752,12 @@ export class CmsWorker {
654
752
  const updates = {
655
753
  name: branch,
656
754
  syncStatus: 'synced',
755
+ // save()'s merge only overwrites keys present in this update (see
756
+ // BranchMetadataFileManager.save()'s spread order) -- a prior
757
+ // syncFailureReason from an earlier failed attempt would otherwise
758
+ // survive forever past this successful sync. Explicitly clear it,
759
+ // same pattern as rebaseFailure's PR-W2 M2 clearing elsewhere.
760
+ syncFailureReason: undefined,
657
761
  };
658
762
  if (result.prUrl)
659
763
  updates.pullRequestUrl = result.prUrl;
@@ -675,18 +779,20 @@ export class CmsWorker {
675
779
  await meta.save({ branch: updates });
676
780
  }
677
781
  catch (err) {
678
- console.error(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
782
+ workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
679
783
  }
680
784
  }
681
785
  /**
682
786
  * Update branch metadata after permanent task failure.
683
- * Sets syncStatus to 'sync-failed' with error details.
787
+ * Sets syncStatus to 'sync-failed' and records `error` (already redacted
788
+ * by the caller, processTaskQueue -- see [HIGH-1] there) as
789
+ * syncFailureReason, so the editor can show WHY, not just that it failed.
684
790
  */
685
- async updateBranchMetadataOnFailure(task, _error) {
791
+ async updateBranchMetadataOnFailure(task, error) {
686
792
  const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
687
793
  if (!branch)
688
794
  return;
689
- const branchPath = path.join(this.contentBranchesPath, branch);
795
+ const branchPath = this.branchWorkspacePath(branch);
690
796
  try {
691
797
  await fs.stat(branchPath);
692
798
  }
@@ -695,15 +801,222 @@ export class CmsWorker {
695
801
  }
696
802
  try {
697
803
  const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
698
- await meta.save({ branch: { name: branch, syncStatus: 'sync-failed' } });
804
+ await meta.save({
805
+ branch: { name: branch, syncStatus: 'sync-failed', syncFailureReason: error },
806
+ });
699
807
  }
700
808
  catch (err) {
701
- console.error(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
809
+ workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
702
810
  }
703
811
  }
704
812
  buildGitHubUrl() {
705
813
  return `https://x-access-token:${this.config.githubToken}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
706
814
  }
815
+ /**
816
+ * The workspace directory for a branch named by its GIT REF name -- the
817
+ * form task payloads carry (`context.branch.name`), not the directory form.
818
+ *
819
+ * These differ for any name outside `[A-Za-z0-9._-]`, `/` being the obvious
820
+ * one: workspaces are provisioned under `sanitizeBranchName(...)` (see
821
+ * paths/branch.ts's `resolveBranchPaths`), so `feature/x` lives in
822
+ * `feature-x`. Joining the raw name instead silently addresses a directory
823
+ * that does not exist -- which for the metadata writers below meant the
824
+ * update was quietly dropped, and for the leased push meant the
825
+ * history-rewrite marker read as absent and the push went out unleased,
826
+ * wedging exactly the branch this workstream exists to unwedge.
827
+ *
828
+ * `name` inside the metadata itself stays the raw ref name; only the path
829
+ * is sanitized.
830
+ */
831
+ branchWorkspacePath(branchRefName) {
832
+ return path.join(this.contentBranchesPath, sanitizeBranchName(branchRefName));
833
+ }
834
+ /**
835
+ * What `remote.git` currently holds for `branchRef`, or null when this
836
+ * branch was never published there (never submitted) or the ref is
837
+ * unreadable.
838
+ *
839
+ * Uses the same explicit `--git-dir` shape as verifyBaseBranchExists()
840
+ * above: reading a bare repo that way does not depend on
841
+ * `safe.bareRepository` being permissive, which is why prod code takes
842
+ * this route rather than the config override the test-only `openBareRepo`
843
+ * helper uses.
844
+ */
845
+ async readPublishedSha(branchRef) {
846
+ try {
847
+ const out = await simpleGit().raw([
848
+ '--git-dir',
849
+ this.remoteGitPath,
850
+ 'rev-parse',
851
+ '--verify',
852
+ `refs/heads/${branchRef}`,
853
+ ]);
854
+ const sha = out.trim();
855
+ return sha.length > 0 ? sha : null;
856
+ }
857
+ catch {
858
+ // Absent from remote.git: this branch was never submitted, so none of
859
+ // its history was ever published. Not an error.
860
+ return null;
861
+ }
862
+ }
863
+ /**
864
+ * Record that this worker rewrote `expectedSha` out of a branch's already
865
+ * published history (see BranchMetadata.historyRewrittenFrom).
866
+ *
867
+ * Set-once: if a marker is already present it is LEFT ALONE. Across two
868
+ * rebases before any GitHub push lands, GitHub still holds the commit the
869
+ * FIRST rebase replaced, so advancing the marker would aim the lease at a
870
+ * commit GitHub never had and permanently wedge the branch.
871
+ *
872
+ * Re-reads metadata rather than trusting the caller's loop-top snapshot:
873
+ * the task loop runs concurrently with syncGit() (see scheduleLoop) and may
874
+ * have cleared the marker while this branch was rebasing.
875
+ */
876
+ async markHistoryRewritten(branchPath, branchDir, expectedSha) {
877
+ const current = await BranchMetadataFileManager.loadOnly(branchPath);
878
+ if (typeof current?.branch.historyRewrittenFrom === 'string')
879
+ return;
880
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
881
+ await meta.save({ branch: { name: branchDir, historyRewrittenFrom: expectedSha } });
882
+ }
883
+ /**
884
+ * Clear the marker once GitHub is confirmed to hold the rewritten history.
885
+ *
886
+ * Best-effort only in the sense that a failure here destroys nothing: the
887
+ * lease still refuses anything unexpected, and the plain-push fallback only
888
+ * ever fast-forwards. It is NOT harmless. A marker that outlives its
889
+ * episode can wedge the NEXT one -- if the base advances before the stale
890
+ * marker is revisited, the queued push leases a commit GitHub has already
891
+ * moved off, falls back to a plain push of a rebased (non-ancestor)
892
+ * history, and fails permanently with a "something else moved it on
893
+ * GitHub" diagnosis that is false: we did.
894
+ *
895
+ * Tracked, with the concurrent-clear race that can drop a marker
896
+ * mid-arming, in
897
+ * .claude/future-tasks/worker-history-rewrite-marker-races.md.
898
+ */
899
+ async clearHistoryRewrittenMarker(branchPath, branchDir) {
900
+ try {
901
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
902
+ // Explicit undefined clears the key -- save()'s merge only overwrites
903
+ // keys present in the update (same pattern as rebaseFailure).
904
+ await meta.save({ branch: { name: branchDir, historyRewrittenFrom: undefined } });
905
+ }
906
+ catch (err) {
907
+ workerLogWarn(` Failed to clear history-rewrite marker for ${branchDir}: ${getErrorMessage(err)}`);
908
+ }
909
+ }
910
+ /**
911
+ * Publish a branch clone's rebased history into `remote.git`, replacing
912
+ * EXACTLY `expectedSha` and nothing else.
913
+ *
914
+ * The lease is the entire safety argument. `--force-with-lease=<ref>:<sha>`
915
+ * refuses unless `remote.git` still stands at `<sha>`, so this can only
916
+ * ever undo the commit our own rebase rewrote away. Callers must never pass
917
+ * "whatever remote.git currently holds" -- see the arming guard in
918
+ * rebaseActiveBranches() for the interleaving where that would silently
919
+ * delete a reviewer's direct push.
920
+ *
921
+ * Returns whether the push landed. A refused lease means a concurrent
922
+ * Lambda push moved the ref; that is logged and retried by the self-heal
923
+ * pass on a later cycle, never thrown.
924
+ */
925
+ async forcePublishToLocalRemote(branchPath, branchRef, expectedSha) {
926
+ // A dedicated instance rather than the caller's: `.env()` replaces the
927
+ // whole child environment, and the rebase loop's instance must keep its
928
+ // ambient one. gitChildEnv (not gitNetworkChildEnv) because this push
929
+ // targets the local bare repo -- and the locale pin is what keeps
930
+ // isStaleLeaseRejection below from silently becoming a no-op.
931
+ const pushGit = simpleGit({ baseDir: branchPath, timeout: { block: this.taskTimeoutMs } });
932
+ pushGit.env(gitChildEnv({}));
933
+ try {
934
+ await pushGit.raw([
935
+ 'push',
936
+ `--force-with-lease=${branchRef}:${expectedSha}`,
937
+ // Real flags must precede --end-of-options; everything after it is
938
+ // positional (see GitManager.push() for the same guard).
939
+ '--end-of-options',
940
+ 'origin',
941
+ `${branchRef}:${branchRef}`,
942
+ ]);
943
+ workerLog(` Published rebased ${branchRef} into remote.git`);
944
+ return true;
945
+ }
946
+ catch (err) {
947
+ const message = redactCredentials(getErrorMessage(err));
948
+ workerLogWarn(isStaleLeaseRejection(message)
949
+ ? ` Did not publish rebased ${branchRef} into remote.git: it moved since this cycle read it (concurrent submit?) -- retrying next cycle`
950
+ : ` Failed to publish rebased ${branchRef} into remote.git: ${message}`);
951
+ return false;
952
+ }
953
+ }
954
+ /**
955
+ * Queue the GitHub hop for a branch whose rewritten history now sits in
956
+ * `remote.git`, so an open PR's head follows the rebase within a cycle
957
+ * instead of waiting for the editor's next submit.
958
+ *
959
+ * Deliberately NOT skipped when a marker is already set: inferring "a task
960
+ * must already be queued" from the marker starves this hop whenever a task
961
+ * was lost, failed permanently, or was never written. Duplicate push tasks
962
+ * are bounded by base-branch advances and are idempotent (a repeat push is
963
+ * a no-op once GitHub holds the tip).
964
+ */
965
+ async enqueueGitHubPush(branchRef) {
966
+ try {
967
+ // Dedupe against tasks actually in flight rather than against the
968
+ // marker: a branch whose GitHub push keeps failing would otherwise
969
+ // gain one task per sync cycle forever.
970
+ for (const status of ['pending', 'processing']) {
971
+ const inFlight = await listTasks(this.taskDir, status);
972
+ if (inFlight.some((t) => t.action === 'push-branch' && t.payload.branch === branchRef)) {
973
+ return;
974
+ }
975
+ }
976
+ await enqueueTask(this.taskDir, { action: 'push-branch', payload: { branch: branchRef } });
977
+ workerLog(` Queued GitHub push for ${branchRef}`);
978
+ }
979
+ catch (err) {
980
+ workerLogWarn(` Failed to queue GitHub push for ${branchRef}: ${getErrorMessage(err)}`);
981
+ }
982
+ }
983
+ /**
984
+ * Complete a rewrite this worker started but did not finish: get the
985
+ * rebased history into `remote.git` and queued for GitHub.
986
+ *
987
+ * Runs from the rebase loop for any branch carrying a marker, whether or
988
+ * not it is behind base this cycle, so an interrupted publish converges
989
+ * without waiting for the next base-branch advance.
990
+ *
991
+ * Always leases on the MARKER, never on remote.git's current tip -- the
992
+ * marker is the one commit we know our own rebase replaced.
993
+ */
994
+ async reconcilePendingRewrite(options) {
995
+ const { branchPath, branchDir, branchRef, headSha, marker } = options;
996
+ const publishedSha = await this.readPublishedSha(branchRef);
997
+ if (publishedSha === null) {
998
+ workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
999
+ return;
1000
+ }
1001
+ if (publishedSha === headSha) {
1002
+ // remote.git already carries the rewrite; only the GitHub hop is left
1003
+ // (a crash between the push and the queue, or a task that was lost).
1004
+ await this.enqueueGitHubPush(branchRef);
1005
+ return;
1006
+ }
1007
+ if (publishedSha === marker) {
1008
+ // The publish into remote.git never landed -- a crash right after the
1009
+ // marker was written, or a lease refused by a concurrent submit.
1010
+ if (await this.forcePublishToLocalRemote(branchPath, branchRef, marker)) {
1011
+ await this.enqueueGitHubPush(branchRef);
1012
+ }
1013
+ return;
1014
+ }
1015
+ // Neither this branch's rebased tip nor the commit its rewrite replaced:
1016
+ // something else moved remote.git. Never force over that.
1017
+ workerLogWarn(` ${branchDir}: remote.git is at ${publishedSha} for ${branchRef}, which is neither the ` +
1018
+ `rebased tip nor the commit the rewrite replaced -- left untouched`);
1019
+ }
707
1020
  async pushBranchToGitHub(branch) {
708
1021
  const git = simpleGit({
709
1022
  baseDir: this.remoteGitPath,
@@ -712,37 +1025,384 @@ export class CmsWorker {
712
1025
  // it hang past the task timeout.
713
1026
  timeout: { block: this.taskTimeoutMs },
714
1027
  });
715
- // Pass URL directly to avoid persisting the token in remote.git/config
716
- await git.push(this.buildGitHubUrl(), branch);
717
- console.log(`Pushed ${branch} to GitHub`);
1028
+ // Force stable (English) git output so isNonFastForwardRejection below
1029
+ // can reliably match it -- git's rejection text is gettext-translated, so
1030
+ // a non-English host would silently turn that classifier into a no-op.
1031
+ // gitNetworkChildEnv (NOT gitChildEnv) because this call talks to GitHub:
1032
+ // it keeps ambient HTTPS_PROXY/GIT_SSL_*/GIT_SSH_COMMAND, which
1033
+ // gitChildEnv's local-ops allowlist deliberately drops.
1034
+ git.env(gitNetworkChildEnv());
1035
+ // [SYNC-H1] If the rebase loop rewrote this branch's already-published
1036
+ // history, GitHub still holds the commit it replaced, so an ordinary
1037
+ // push is non-fast-forward forever. Push under a lease keyed to exactly
1038
+ // that commit: it moves GitHub off the commit we rewrote away, and
1039
+ // refuses in every other case (including a commit someone else pushed).
1040
+ const branchPath = this.branchWorkspacePath(branch);
1041
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
1042
+ const marker = metaFile?.branch.historyRewrittenFrom;
1043
+ // What this push will actually send: remote.git's tip for the branch.
1044
+ const outgoingSha = await this.readPublishedSha(branch);
1045
+ try {
1046
+ if (marker) {
1047
+ await git.raw([
1048
+ 'push',
1049
+ `--force-with-lease=${branch}:${marker}`,
1050
+ '--end-of-options',
1051
+ this.buildGitHubUrl(),
1052
+ `${branch}:${branch}`,
1053
+ ]);
1054
+ }
1055
+ else {
1056
+ // Pass URL directly to avoid persisting the token in remote.git/config
1057
+ await git.push(this.buildGitHubUrl(), branch);
1058
+ }
1059
+ }
1060
+ catch (err) {
1061
+ const message = getErrorMessage(err);
1062
+ // A refused lease means GitHub is not at the commit we rewrote, so the
1063
+ // marker is stale -- which is routine, not exceptional: tasks are
1064
+ // re-run after a crash (recoverOrphanedTasks), and the marker survives
1065
+ // any failure to clear it. Two benign shapes reach here, and both are
1066
+ // ordinary fast-forwards that a lease has no business blocking:
1067
+ // GitHub already holds the rewritten history from an earlier attempt,
1068
+ // or the branch has since moved past it.
1069
+ //
1070
+ // Retry PLAIN, and let git adjudicate. A non-forced push succeeds if
1071
+ // and only if it fast-forwards, so it can never destroy anything --
1072
+ // there is no ancestry check to get wrong here, and no extra network
1073
+ // round trip to read GitHub's tip. Only if THAT is also rejected has
1074
+ // the branch genuinely diverged.
1075
+ //
1076
+ // (Verified: git evaluates the lease only when it actually has an
1077
+ // update to apply. An up-to-date ref with a stale lease prints
1078
+ // "Everything up-to-date" and exits 0, so the already-landed case is
1079
+ // usually absorbed above and never even reaches this branch.)
1080
+ if (marker && isStaleLeaseRejection(message)) {
1081
+ try {
1082
+ await git.push(this.buildGitHubUrl(), branch);
1083
+ }
1084
+ catch (retryErr) {
1085
+ const retryMessage = getErrorMessage(retryErr);
1086
+ if (isNonFastForwardRejection(retryMessage)) {
1087
+ throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
1088
+ `deployment last published nor an ancestor of what it is pushing, so the branch ` +
1089
+ `has genuinely diverged and nothing was overwritten. Something else moved it on ` +
1090
+ `GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
1091
+ }
1092
+ throw retryErr;
1093
+ }
1094
+ // The lease was refused, so GitHub is provably not at the marker: it
1095
+ // has moved past the rewritten commit and the marker is spent.
1096
+ await this.clearHistoryRewrittenMarker(branchPath, branch);
1097
+ workerLog(`Pushed ${branch} to GitHub (GitHub had already moved past the rewritten commit)`);
1098
+ return;
1099
+ }
1100
+ // An ordinary non-fast-forward rejection: GitHub has commits this
1101
+ // deployment never published. Retrying the identical push can never
1102
+ // succeed (DEP-L1's git-failure-is-transient carve-out does NOT apply
1103
+ // here), so fail fast instead of burning the task's retry budget.
1104
+ // Deliberately does NOT advise renaming the branch: a branch reaching
1105
+ // this point usually has an open PR, and renaming would orphan it.
1106
+ if (isNonFastForwardRejection(message)) {
1107
+ throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
1108
+ `published, so the branch has diverged and needs reconciling. Something else moved it ` +
1109
+ `on GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
1110
+ }
1111
+ throw err;
1112
+ }
1113
+ // Clear the marker only once GitHub is confirmed to hold something other
1114
+ // than the commit we rewrote. A push that sent nothing new (remote.git
1115
+ // still at the marker because its own publish has not landed yet) must
1116
+ // leave the marker set -- it is the sole trigger for the self-heal pass.
1117
+ if (marker && outgoingSha && outgoingSha !== marker) {
1118
+ await this.clearHistoryRewrittenMarker(branchPath, branch);
1119
+ }
1120
+ workerLog(`Pushed ${branch} to GitHub`);
718
1121
  }
719
1122
  /**
720
- * Push any canopycms-settings-* branches from remote.git to GitHub.
721
- * Non-fatal: a no-op push for up-to-date branches just succeeds quietly.
1123
+ * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
1124
+ * remote.git to GitHub. Non-fatal: a no-op push for an up-to-date branch
1125
+ * just succeeds quietly.
1126
+ *
1127
+ * Deliberately narrowed to one branch — this used to push EVERY local
1128
+ * branch matching `canopycms-settings-*`. With the tracking-namespace fetch
1129
+ * fix (GITHUB_TRACKING_REF_PREFIX), `reconcileTrackedBranches` creates local
1130
+ * heads for branches that exist on GitHub, so ANOTHER deployment's settings
1131
+ * branch (sharing this same GitHub repo) can legitimately show up as a
1132
+ * local head here too. Pushing it would be this deployment shipping
1133
+ * settings state it doesn't own.
722
1134
  */
723
- async pushSettingsBranches(git) {
1135
+ async pushSettingsBranches(git, trackedNames) {
724
1136
  try {
1137
+ // Resolved once here rather than at each use below: ensureSettingsBranch()
1138
+ // is idempotent, but a local reads better and keeps the eight references
1139
+ // in this method obviously talking about one value.
1140
+ const settingsBranch = this.ensureSettingsBranch();
725
1141
  const branches = await git.branch();
726
- const settingsBranches = branches.all.filter((b) => b.startsWith('canopycms-settings-'));
727
- for (const branch of settingsBranches) {
1142
+ const settingsBranches = branches.all.filter((b) => b.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX));
1143
+ const foreign = settingsBranches.filter((b) => b !== settingsBranch);
1144
+ if (foreign.length > 0) {
1145
+ // Signal, not an error: this is exactly the "two deployments, one repo"
1146
+ // condition this workstream exists to make visible. Never push these.
1147
+ workerLogWarn(`Found settings branch(es) not owned by this deployment (${settingsBranch}): ` +
1148
+ `${foreign.join(', ')}. Another CanopyCMS deployment may share this GitHub repo. Not pushing them.`);
1149
+ }
1150
+ // Check the full branch list, not the `canopycms-settings-*` subset: an
1151
+ // adopter-supplied `settingsBranch` override need not carry that prefix.
1152
+ const ownBranchMissing = !branches.all.includes(settingsBranch);
1153
+ // [SYNC-M3] A settings branch present in remote.git but absent from
1154
+ // GitHub's tracking refs was pushed here LOCALLY -- only this
1155
+ // deployment's own API can write to remote.git -- and has never
1156
+ // reached GitHub. That is the discriminating signature: in the
1157
+ // SUPPORTED two-deployments-one-repo case the foreign branch arrives
1158
+ // through the GitHub fetch and therefore always has a tracking ref, so
1159
+ // the warn above cannot tell the two apart on its own and a
1160
+ // "owned-branch-absent" test alone would fire on every deployment that
1161
+ // simply has not had a settings edit yet.
1162
+ //
1163
+ // With this deployment's own branch also missing, the API and this
1164
+ // worker have resolved different deploymentNames, and every settings
1165
+ // change the API commits is stranded in remote.git forever.
1166
+ const strandedLocal = foreign.filter((b) => !trackedNames.has(b));
1167
+ if (strandedLocal.length > 0) {
1168
+ workerLogWarn(ownBranchMissing
1169
+ ? `Settings branch mismatch: this worker owns "${settingsBranch}", which does not ` +
1170
+ `exist in remote.git, while ${strandedLocal.join(', ')} exist(s) here and has never ` +
1171
+ `been pushed to GitHub. The API and this worker disagree about deploymentName, so ` +
1172
+ `settings changes are NOT reaching GitHub. Set CANOPYCMS_DEPLOYMENT_NAME (or ` +
1173
+ `settingsBranch) on this worker to match what the API resolves.`
1174
+ : `Settings branch(es) ${strandedLocal.join(', ')} exist in remote.git but not on ` +
1175
+ `GitHub, and are not owned by this deployment (${settingsBranch}) -- nothing ` +
1176
+ `will ever push them onward. Check that deploymentName matches across this ` +
1177
+ `deployment's API and worker.`);
1178
+ }
1179
+ if (ownBranchMissing) {
1180
+ // Not created locally yet — nothing to push.
1181
+ return;
1182
+ }
1183
+ try {
1184
+ await git.push(this.buildGitHubUrl(), settingsBranch);
1185
+ workerLog(`Pushed settings branch ${settingsBranch} to GitHub`);
1186
+ }
1187
+ catch (err) {
1188
+ // Non-fatal: branch may already be up-to-date. This call site has no
1189
+ // task to throw a PermanentTaskError into (unlike pushBranchToGitHub)
1190
+ // and is deliberately not restructured to add one -- but a
1191
+ // non-fast-forward rejection here is the highest-signal instance of
1192
+ // this whole failure class: it means another CanopyCMS deployment's
1193
+ // worker already pushed ITS OWN state to its own settings branch on
1194
+ // GitHub (an actual settings-branch name collision, not just the
1195
+ // "foreign branch found locally" case warned about above), so make
1196
+ // that explicit instead of a generic "push failed" line.
1197
+ const message = getErrorMessage(err);
1198
+ if (isNonFastForwardRejection(message)) {
1199
+ workerLogWarn(`Settings push for ${settingsBranch} was rejected (non-fast-forward): another ` +
1200
+ `CanopyCMS deployment appears to own this settings branch on GitHub. Settings from ` +
1201
+ `that deployment will NOT be overwritten; this deployment's local settings changes ` +
1202
+ `were not pushed. Rename this deployment's settings branch (config.settingsBranch or ` +
1203
+ `deploymentName) to resolve the collision.`);
1204
+ }
1205
+ else {
1206
+ workerLogWarn(`Settings push for ${settingsBranch}:`, message);
1207
+ }
1208
+ }
1209
+ }
1210
+ catch (err) {
1211
+ workerLogWarn('Failed to list branches for settings push:', err instanceof Error ? err.message : err);
1212
+ }
1213
+ }
1214
+ /**
1215
+ * Bring `refs/heads/*` in `remote.git` toward what was just fetched into
1216
+ * `GITHUB_TRACKING_REF_PREFIX` -- WITHOUT ever force-rewinding or deleting
1217
+ * a local head. This is the non-destructive replacement for what the old
1218
+ * `+refs/heads/*:refs/heads/*` fetch refspec used to do implicitly (and
1219
+ * destructively) as part of the fetch itself; see
1220
+ * `GITHUB_TRACKING_REF_PREFIX`'s doc comment for the two failure modes
1221
+ * that refspec caused.
1222
+ *
1223
+ * Per tracked branch:
1224
+ * - no local `refs/heads/<name>` yet -> create it at the tracked commit
1225
+ * (a branch created on GitHub, or by another deployment sharing this
1226
+ * GitHub repo, becomes visible locally).
1227
+ * - local is a strict ancestor of tracked (behind) -> fast-forward it.
1228
+ * - local === tracked -> nothing to do.
1229
+ * - tracked is a strict ancestor of local (ahead) -> LEAVE IT ALONE. This
1230
+ * is unpushed editor/settings work; the queued push task (or
1231
+ * pushSettingsBranches) ships it. This is exactly the branch state the
1232
+ * old refspec used to destroy.
1233
+ * - neither is an ancestor of the other (diverged) -> LEAVE IT ALONE and
1234
+ * count/log it. A real collision (e.g. another deployment moved the
1235
+ * same branch name on GitHub); the next push attempt will be rejected
1236
+ * non-fast-forward, which is the correct, visible outcome -- this
1237
+ * method must never silently pick a winner. The one expected, benign
1238
+ * form of this -- our own rebase loop having published a rewrite into
1239
+ * remote.git with the GitHub push still queued -- is split out into the
1240
+ * `rewritten` bucket so the collision warning stays meaningful.
1241
+ *
1242
+ * Never deletes a local head: a branch removed on GitHub simply stops
1243
+ * being tracked here; the local ref persists until removed through its
1244
+ * own explicit path (the sync loop must not be one of them).
1245
+ *
1246
+ * `remote.git` is bare, so there is no worktree to invalidate by moving
1247
+ * these refs -- unlike a non-bare repo, updating the ref that happens to
1248
+ * be "checked out" is a non-issue here.
1249
+ *
1250
+ * Concurrency: `remote.git` is bare and on EFS, and the Lambda pushes into
1251
+ * it concurrently (`GitManager.push()`'s `target:target` refspec) while
1252
+ * this runs. Every `update-ref` below passes the expected old value (the
1253
+ * all-zeros OID for "must not exist yet" on creation, the previously-read
1254
+ * SHA for the fast-forward case) so a concurrent Lambda write landing in
1255
+ * the gap between the read and the write loses the ref update instead of
1256
+ * being silently clobbered -- the branch is simply revisited next cycle.
1257
+ */
1258
+ async reconcileTrackedBranches(git) {
1259
+ const GIT_ZERO_OID = '0000000000000000000000000000000000000000';
1260
+ const created = [];
1261
+ const fastForwarded = [];
1262
+ const ahead = [];
1263
+ const diverged = [];
1264
+ const rewritten = [];
1265
+ // One invocation enumerates both namespaces: refs/heads/<name> (what the
1266
+ // Lambda pushes into and branch clones read from) and
1267
+ // GITHUB_TRACKING_REF_PREFIX<name> (GitHub's tip, just fetched above).
1268
+ const raw = await git.raw([
1269
+ 'for-each-ref',
1270
+ '--format=%(refname) %(objectname)',
1271
+ 'refs/heads/',
1272
+ GITHUB_TRACKING_REF_PREFIX,
1273
+ ]);
1274
+ const heads = new Map();
1275
+ const tracked = new Map();
1276
+ for (const line of raw.split('\n')) {
1277
+ const trimmed = line.trim();
1278
+ if (!trimmed)
1279
+ continue;
1280
+ const [refname, sha] = trimmed.split(' ');
1281
+ if (refname.startsWith('refs/heads/')) {
1282
+ heads.set(refname.slice('refs/heads/'.length), sha);
1283
+ }
1284
+ else if (refname.startsWith(GITHUB_TRACKING_REF_PREFIX)) {
1285
+ tracked.set(refname.slice(GITHUB_TRACKING_REF_PREFIX.length), sha);
1286
+ }
1287
+ }
1288
+ for (const [name, trackedSha] of tracked) {
1289
+ const localSha = heads.get(name);
1290
+ const localRef = `refs/heads/${name}`;
1291
+ if (!localSha) {
728
1292
  try {
729
- await git.push(this.buildGitHubUrl(), branch);
730
- console.log(`Pushed settings branch ${branch} to GitHub`);
1293
+ // Zero old-value asserts the ref does not already exist -- guards
1294
+ // against a concurrent Lambda push creating this exact branch
1295
+ // name between the for-each-ref read above and this update.
1296
+ await git.raw(['update-ref', localRef, trackedSha, GIT_ZERO_OID]);
1297
+ created.push(name);
731
1298
  }
732
1299
  catch (err) {
733
- // Non-fatal: branch may already be up-to-date or not yet created
734
- console.warn(`Settings push for ${branch}:`, err instanceof Error ? err.message : err);
1300
+ workerLogWarn(` Tracked-branch reconcile: failed to create local ref for ${name} (concurrent update?): ${getErrorMessage(err)}`);
1301
+ }
1302
+ continue;
1303
+ }
1304
+ if (localSha === trackedSha)
1305
+ continue; // nothing to do
1306
+ // [SYNC-M2] Everything below is per-branch best-effort. The rev-list
1307
+ // used to sit between the two guarded update-ref calls with no guard
1308
+ // of its own, so a single ref pointing at a missing or partially
1309
+ // written object -- plausible on EFS with the Lambda writing
1310
+ // concurrently -- threw out of this loop, out of syncGit()'s try, and
1311
+ // skipped pushSettingsBranches(), refreshBaseBranchWorkspace() and
1312
+ // rebaseActiveBranches() entirely. Nothing self-healed, so it recurred
1313
+ // every cycle. One unreadable ref must cost its own branch, not the
1314
+ // whole sync cycle.
1315
+ try {
1316
+ // Count commits unique to each side in one call: left = commits local
1317
+ // has that tracked doesn't (ahead), right = commits tracked has that
1318
+ // local doesn't (behind). Exits 0 regardless of ancestry direction,
1319
+ // unlike `merge-base --is-ancestor` (which simple-git would throw on
1320
+ // a non-zero exit for the common "not an ancestor" case).
1321
+ const counts = (await git.raw(['rev-list', '--left-right', '--count', `${localSha}...${trackedSha}`])).trim();
1322
+ const [leftStr, rightStr] = counts.split(/\s+/);
1323
+ const localAheadCount = parseInt(leftStr, 10);
1324
+ const localBehindCount = parseInt(rightStr, 10);
1325
+ if (!Number.isInteger(localAheadCount) || !Number.isInteger(localBehindCount)) {
1326
+ // Unparseable output means we could not read this branch, NOT that
1327
+ // it diverged: falling through to the `diverged` bucket below would
1328
+ // warn operators about a cross-deployment collision that never
1329
+ // happened (parseInt yields NaN, and NaN fails both comparisons).
1330
+ workerLogWarn(` Tracked-branch reconcile: skipping ${name}: unparseable rev-list output ${JSON.stringify(counts)}`);
1331
+ continue;
735
1332
  }
1333
+ if (localAheadCount === 0 && localBehindCount > 0) {
1334
+ try {
1335
+ await git.raw(['update-ref', localRef, trackedSha, localSha]);
1336
+ fastForwarded.push(name);
1337
+ }
1338
+ catch (err) {
1339
+ // Concurrent Lambda push moved the ref since the read above --
1340
+ // the guard did its job; this branch is simply revisited next cycle.
1341
+ workerLogWarn(` Tracked-branch reconcile: failed to fast-forward ${name} (concurrent update?): ${getErrorMessage(err)}`);
1342
+ }
1343
+ }
1344
+ else if (localBehindCount === 0 && localAheadCount > 0) {
1345
+ // Unpushed local work -- exactly what the old destructive refspec
1346
+ // used to force-rewind or delete. Leave it.
1347
+ ahead.push(name);
1348
+ }
1349
+ else if (await this.hasPendingHistoryRewrite(name)) {
1350
+ // [SYNC-H1] Our own rebase published a rewrite into remote.git and
1351
+ // the GitHub push has not landed yet. Ref-level this is identical
1352
+ // to a collision, but it is expected and self-resolving, so it must
1353
+ // not fire the collision warning below.
1354
+ rewritten.push(name);
1355
+ }
1356
+ else {
1357
+ // Neither side is an ancestor of the other. Leave both alone.
1358
+ diverged.push(name);
1359
+ }
1360
+ }
1361
+ catch (err) {
1362
+ workerLogWarn(` Tracked-branch reconcile: skipping ${name} (unreadable ref or object?): ${getErrorMessage(err)}`);
1363
+ continue;
736
1364
  }
737
1365
  }
738
- catch (err) {
739
- console.warn('Failed to list branches for settings push:', err instanceof Error ? err.message : err);
1366
+ if (diverged.length > 0) {
1367
+ workerLogWarn(` Tracked-branch reconcile: ${diverged.length} branch(es) diverged from GitHub and were left untouched: ${diverged.join(', ')}`);
1368
+ }
1369
+ if (rewritten.length > 0) {
1370
+ workerLog(` Tracked-branch reconcile: ${rewritten.length} branch(es) rebased locally with the GitHub push still pending: ${rewritten.join(', ')}`);
1371
+ }
1372
+ // trackedNames is returned alongside the summary (rather than folded into
1373
+ // it) because it is a working set for pushSettingsBranches' stranded-
1374
+ // branch check, not part of the worker-status snapshot -- it lists every
1375
+ // branch on GitHub and would bloat worker-status.json for no reader.
1376
+ return {
1377
+ summary: { created, fastForwarded, ahead, diverged, rewritten },
1378
+ trackedNames: new Set(tracked.keys()),
1379
+ };
1380
+ }
1381
+ /**
1382
+ * Whether this branch carries a pending history rewrite -- the rebase loop
1383
+ * rewrote already-published history and the GitHub push has not landed yet
1384
+ * (see BranchMetadata.historyRewrittenFrom).
1385
+ *
1386
+ * `branchName` is a git ref name; branch workspaces are directories named
1387
+ * with the sanitized form, hence the conversion. Best-effort: a settings
1388
+ * branch (no workspace at all), a missing directory or an unreadable
1389
+ * branch.json all mean "no known rewrite", which is the conservative
1390
+ * answer -- it keeps the branch in the louder `diverged` bucket.
1391
+ */
1392
+ async hasPendingHistoryRewrite(branchName) {
1393
+ try {
1394
+ const branchPath = path.join(this.contentBranchesPath, sanitizeBranchName(branchName));
1395
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
1396
+ return typeof metaFile?.branch.historyRewrittenFrom === 'string';
1397
+ }
1398
+ catch {
1399
+ return false;
740
1400
  }
741
1401
  }
742
1402
  async syncGit() {
743
1403
  if (!this.running)
744
1404
  return;
745
- console.log('Syncing git...');
1405
+ workerLog('Syncing git...');
746
1406
  const cycleStartedAt = Date.now();
747
1407
  const git = simpleGit({
748
1408
  baseDir: this.remoteGitPath,
@@ -751,6 +1411,11 @@ export class CmsWorker {
751
1411
  // is inactivity-based, so a slow-but-flowing transfer is unaffected.
752
1412
  timeout: { block: this.taskTimeoutMs },
753
1413
  });
1414
+ // This instance also drives pushSettingsBranches(git) below, whose
1415
+ // classification of a rejected settings-branch push (see that method)
1416
+ // needs the same stable-English guarantee as pushBranchToGitHub. Network
1417
+ // env for the same reason: the fetch and push here reach GitHub.
1418
+ git.env(gitNetworkChildEnv());
754
1419
  // PR-W1: the whole cycle is wrapped so both outcomes -- success and
755
1420
  // hard failure (e.g. the fetch throwing against a poisoned remote.git)
756
1421
  // -- record a worker-status.json snapshot. The status write itself is
@@ -759,14 +1424,28 @@ export class CmsWorker {
759
1424
  // on a failed one. On failure we rethrow so scheduleLoop's existing
760
1425
  // per-cycle catch stays the loud path.
761
1426
  try {
762
- // Fetch all branches from GitHub using direct URL (no named remote)
763
- // We use raw git commands since simple-git's fetch() with a URL
764
- // doesn't support --prune directly
765
- await git.raw(['fetch', this.buildGitHubUrl(), '--prune', '+refs/heads/*:refs/heads/*']);
766
- console.log('Fetched from GitHub');
1427
+ // Fetch all branches from GitHub using direct URL (no named remote),
1428
+ // into the GITHUB_TRACKING_REF_PREFIX remote-tracking namespace rather
1429
+ // than refs/heads/* directly -- see that constant's doc comment for
1430
+ // the destructive-fetch bug this avoids. We use raw git commands since
1431
+ // simple-git's fetch() with a URL doesn't support --prune directly.
1432
+ await git.raw([
1433
+ 'fetch',
1434
+ this.buildGitHubUrl(),
1435
+ '--prune',
1436
+ `+refs/heads/*:${GITHUB_TRACKING_REF_PREFIX}*`,
1437
+ ]);
1438
+ workerLog('Fetched from GitHub');
1439
+ // Bring refs/heads/* toward what was just fetched, WITHOUT ever
1440
+ // force-rewinding or deleting a local head -- see
1441
+ // reconcileTrackedBranches()'s doc comment.
1442
+ const { summary: trackedSummary, trackedNames } = await this.reconcileTrackedBranches(git);
767
1443
  // Push settings branches to GitHub (belt-and-suspenders for task queue).
768
1444
  // Ensures settings reach GitHub even if a task queue entry is lost.
769
- await this.pushSettingsBranches(git);
1445
+ // Ordering relative to the fetch/reconcile above is no longer a
1446
+ // correctness dependency now that the fetch can't clobber refs/heads/*
1447
+ // -- this could run before or after them just as safely.
1448
+ await this.pushSettingsBranches(git, trackedNames);
770
1449
  await this.refreshBaseBranchWorkspace();
771
1450
  const rebaseSummary = await this.rebaseActiveBranches();
772
1451
  // Periodically clean up old completed/failed tasks
@@ -777,7 +1456,7 @@ export class CmsWorker {
777
1456
  // place actual removal happens.
778
1457
  const trashRemoved = await this.cleanupTrashedBranchDirs();
779
1458
  if (trashRemoved > 0) {
780
- console.log(`Removed ${trashRemoved} expired trashed branch dir(s)`);
1459
+ workerLog(`Removed ${trashRemoved} expired trashed branch dir(s)`);
781
1460
  }
782
1461
  const report = this.ensureStatusReport();
783
1462
  report.lastGitSyncAt = new Date().toISOString();
@@ -787,8 +1466,9 @@ export class CmsWorker {
787
1466
  rebased: rebaseSummary.rebased,
788
1467
  skippedDirty: rebaseSummary.skippedDirty,
789
1468
  failed: rebaseSummary.failed,
1469
+ tracked: trackedSummary,
790
1470
  };
791
- await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
1471
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
792
1472
  }
793
1473
  catch (err) {
794
1474
  const report = this.ensureStatusReport();
@@ -799,7 +1479,7 @@ export class CmsWorker {
799
1479
  message: redactCredentials(getErrorMessage(err)),
800
1480
  at: new Date().toISOString(),
801
1481
  };
802
- await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
1482
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
803
1483
  throw err;
804
1484
  }
805
1485
  }
@@ -833,7 +1513,7 @@ export class CmsWorker {
833
1513
  const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
834
1514
  if (!stampDate) {
835
1515
  if (!loggedUnparseable) {
836
- console.log(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
1516
+ workerLog(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
837
1517
  loggedUnparseable = true;
838
1518
  }
839
1519
  continue;
@@ -850,7 +1530,7 @@ export class CmsWorker {
850
1530
  removed++;
851
1531
  }
852
1532
  catch (err) {
853
- console.error(`CanopyCMS: Failed to remove trashed branch dir ${name}:`, getErrorMessage(err));
1533
+ workerLogError(`CanopyCMS: Failed to remove trashed branch dir ${name}:`, getErrorMessage(err));
854
1534
  }
855
1535
  }
856
1536
  return removed;
@@ -890,7 +1570,7 @@ export class CmsWorker {
890
1570
  gitDirStat = null;
891
1571
  }
892
1572
  if (!gitDirStat || !gitDirStat.isDirectory()) {
893
- console.log(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
1573
+ workerLog(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
894
1574
  return;
895
1575
  }
896
1576
  const baseGit = simpleGit({
@@ -921,7 +1601,7 @@ export class CmsWorker {
921
1601
  const status = await baseGit.status();
922
1602
  const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
923
1603
  if (trackedDirty.length > 0) {
924
- console.error(`Base branch workspace (${this.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
1604
+ workerLogError(`Base branch workspace (${this.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
925
1605
  return;
926
1606
  }
927
1607
  // Raw (unsanitized) name from here on: these are git ref operations
@@ -945,7 +1625,7 @@ export class CmsWorker {
945
1625
  await baseGit.merge(['--ff-only', fetchedTip]);
946
1626
  }
947
1627
  catch (err) {
948
- console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
1628
+ workerLogError(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
949
1629
  return;
950
1630
  }
951
1631
  await invalidateBranchContentCaches(basePath);
@@ -968,12 +1648,12 @@ export class CmsWorker {
968
1648
  });
969
1649
  }
970
1650
  // One concise per-cycle line -- the diagnostic for the next live deploy.
971
- console.log(behindCount > 0
1651
+ workerLog(behindCount > 0
972
1652
  ? `Base branch workspace (${this.baseBranch}): fast-forwarded ${behindCount} commit(s)`
973
1653
  : `Base branch workspace (${this.baseBranch}): up to date`);
974
1654
  }
975
1655
  catch (err) {
976
- console.error(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
1656
+ workerLogError(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
977
1657
  }
978
1658
  }
979
1659
  /**
@@ -1008,7 +1688,7 @@ export class CmsWorker {
1008
1688
  await meta.save({
1009
1689
  branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
1010
1690
  });
1011
- console.log(` PR #${prNumber} for ${branchDir} is merged -> archived`);
1691
+ workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
1012
1692
  return;
1013
1693
  }
1014
1694
  const newState = data.state === 'closed' ? 'closed' : 'open';
@@ -1020,11 +1700,11 @@ export class CmsWorker {
1020
1700
  return;
1021
1701
  const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1022
1702
  await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
1023
- console.log(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
1703
+ workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
1024
1704
  }
1025
1705
  catch (err) {
1026
1706
  // Non-fatal: transient GitHub/network errors are retried next cycle.
1027
- console.warn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
1707
+ workerLogWarn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
1028
1708
  }
1029
1709
  finally {
1030
1710
  clearTimeout(timer);
@@ -1082,7 +1762,7 @@ export class CmsWorker {
1082
1762
  // against the same corrupt file would just throw again) as well as
1083
1763
  // any other load/save failure -- recording is best-effort
1084
1764
  // observability, never allowed to abort the branch loop.
1085
- console.warn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
1765
+ workerLogWarn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
1086
1766
  }
1087
1767
  }
1088
1768
  async rebaseActiveBranches() {
@@ -1113,12 +1793,12 @@ export class CmsWorker {
1113
1793
  try {
1114
1794
  const stat = await fs.stat(gitDir);
1115
1795
  if (!stat.isDirectory()) {
1116
- console.log(` Skipping ${branchDir}: .git is not a directory`);
1796
+ workerLog(` Skipping ${branchDir}: .git is not a directory`);
1117
1797
  continue;
1118
1798
  }
1119
1799
  }
1120
1800
  catch {
1121
- console.log(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
1801
+ workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
1122
1802
  continue;
1123
1803
  }
1124
1804
  // The base branch's own clone is refreshed ff-only by
@@ -1128,7 +1808,7 @@ export class CmsWorker {
1128
1808
  // metadata on it. Compare sanitized-vs-sanitized: branchDir is a
1129
1809
  // filesystem name (already sanitized), this.baseBranch is raw.
1130
1810
  if (branchDir === this.sanitizedBaseBranch) {
1131
- console.log(` Skipping ${branchDir}: base branch (refreshed separately)`);
1811
+ workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
1132
1812
  continue;
1133
1813
  }
1134
1814
  try {
@@ -1142,12 +1822,12 @@ export class CmsWorker {
1142
1822
  // - archived: already merged, no reason to rebase and no PR left to
1143
1823
  // poll (avoid the wasted API call).
1144
1824
  if (branchStatus === 'submitted' || branchStatus === 'approved') {
1145
- console.log(` Skipping ${branchDir} (${branchStatus})`);
1825
+ workerLog(` Skipping ${branchDir} (${branchStatus})`);
1146
1826
  await this.pollMergeState(branchDir, branchPath, metaFile);
1147
1827
  continue;
1148
1828
  }
1149
1829
  if (branchStatus === 'archived') {
1150
- console.log(` Skipping ${branchDir} (${branchStatus})`);
1830
+ workerLog(` Skipping ${branchDir} (${branchStatus})`);
1151
1831
  continue;
1152
1832
  }
1153
1833
  const branchGit = simpleGit({
@@ -1165,10 +1845,33 @@ export class CmsWorker {
1165
1845
  // the catch block will abort safely — the branch stays behind and retries next cycle.
1166
1846
  const dirtyCheck = await branchGit.status();
1167
1847
  if (dirtyCheck.files.length > 0) {
1168
- console.log(` Skipping ${branchDir}: has uncommitted changes`);
1848
+ workerLog(` Skipping ${branchDir}: has uncommitted changes`);
1169
1849
  skippedDirty.push(branchDir);
1170
1850
  continue;
1171
1851
  }
1852
+ // The clone's own ref name: branchDir is the sanitized DIRECTORY
1853
+ // name and need not match it. A literal 'HEAD' means a detached
1854
+ // clone (e.g. a crashed rebase left one behind) -- nothing below can
1855
+ // safely name a ref then, so every publish path stays disarmed,
1856
+ // which is the safe direction.
1857
+ const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
1858
+ const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
1859
+ // [SYNC-H1] Self-heal: finish an interrupted publish even when this
1860
+ // branch is not behind base. Every crash window in the arming
1861
+ // sequence below leaves the marker set with the work unfinished, and
1862
+ // this is what completes it -- without it, one lost lease race would
1863
+ // strand the branch until the base branch happened to advance again.
1864
+ // Gated on the loop-top snapshot, so unmarked branches (nearly all
1865
+ // of them, every cycle) cost nothing extra.
1866
+ if (canPublish && metaFile?.branch.historyRewrittenFrom) {
1867
+ await this.reconcilePendingRewrite({
1868
+ branchPath,
1869
+ branchDir,
1870
+ branchRef,
1871
+ headSha: (await branchGit.revparse(['HEAD'])).trim(),
1872
+ marker: metaFile.branch.historyRewrittenFrom,
1873
+ });
1874
+ }
1172
1875
  await branchGit.fetch('origin', this.baseBranch);
1173
1876
  // Use rev-list instead of status.behind — status.behind only works when the
1174
1877
  // branch has an upstream tracking branch configured, which isn't guaranteed
@@ -1216,7 +1919,13 @@ export class CmsWorker {
1216
1919
  });
1217
1920
  continue;
1218
1921
  }
1219
- console.log(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1922
+ workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1923
+ // Read BOTH sides before rewriting anything: the arming guard after
1924
+ // the rebase compares what remote.git published against what this
1925
+ // clone is about to rebase away. Reading them afterwards would be
1926
+ // useless -- the clone's pre-rebase tip is exactly what disappears.
1927
+ const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
1928
+ const publishedSha = canPublish ? await this.readPublishedSha(branchRef) : null;
1220
1929
  // Resolve-and-continue loop: keep branch version for conflicting files, then continue
1221
1930
  // Non-conflicting files get main's changes; conflicting files keep branch version.
1222
1931
  const conflictedFiles = [];
@@ -1267,7 +1976,7 @@ export class CmsWorker {
1267
1976
  // the rebase didn't complete so we can't determine the true conflict
1268
1977
  // state. Previous metadata (possibly stale) is preserved until the
1269
1978
  // next successful rebase cycle corrects it.
1270
- console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
1979
+ workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
1271
1980
  failureReason = msg || 'Unknown error';
1272
1981
  await branchGit.rebase(['--abort']).catch(() => { });
1273
1982
  break;
@@ -1280,7 +1989,7 @@ export class CmsWorker {
1280
1989
  // error" break above -- MAX_ROUNDS exhaustion is a distinct exit
1281
1990
  // path with no error message of its own, so the warn text must
1282
1991
  // not conflate the two.
1283
- console.warn(failureReason !== undefined
1992
+ workerLogWarn(failureReason !== undefined
1284
1993
  ? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
1285
1994
  : ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
1286
1995
  await branchGit.rebase(['--abort']).catch(() => { });
@@ -1311,8 +2020,26 @@ export class CmsWorker {
1311
2020
  // Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
1312
2021
  // .collection.json files have no ID themselves (extractIdFromFilename returns null
1313
2022
  // for dot-prefixed files), so we extract the ID from the parent directory instead.
1314
- // The root content directory (e.g., "content/") has no embedded ID, so we use
1315
- // ROOT_COLLECTION_ID as a sentinel — but only for the configured contentRoot.
2023
+ // The root content directory (e.g., "content/", or "cms/content/" for a
2024
+ // multi-segment contentRoot) has no embedded ID, so we use ROOT_COLLECTION_ID
2025
+ // as a sentinel — but only for the configured contentRoot.
2026
+ //
2027
+ // Two different notions of "parent" are needed below, and conflating them
2028
+ // reproduces the exact bug this comparison guards against (see
2029
+ // schema-store.ts's contentRootName doc comment for the same shape elsewhere):
2030
+ // - `parentDir` (a basename) recovers a SUB-collection's own embedded ID
2031
+ // (e.g. "posts.cNbR5xFm2Kpd" -> "cNbR5xFm2Kpd") — correct as a basename,
2032
+ // since a collection directory carries its ID in its own name, one path
2033
+ // segment.
2034
+ // - `parentPath` (the full relative parent path, normalized) is what must be
2035
+ // compared against `this.contentRoot`, because `contentRoot` is documented
2036
+ // (config/helpers.ts) as allowed to span multiple segments (e.g.
2037
+ // "cms/content"). Comparing a basename ("content") against that full value
2038
+ // is always false, which silently drops the root collection's conflict.
2039
+ // Git reports POSIX-style paths and the configured value may be authored
2040
+ // with either separator, so both sides go through normalizeFilesystemPath
2041
+ // before comparing.
2042
+ const normalizedContentRoot = normalizeFilesystemPath(this.contentRoot);
1316
2043
  const conflictIds = [...new Set(conflictedFiles)]
1317
2044
  .map((f) => {
1318
2045
  const fileId = extractIdFromFilename(path.basename(f));
@@ -1322,9 +2049,10 @@ export class CmsWorker {
1322
2049
  const dirId = extractIdFromFilename(parentDir);
1323
2050
  if (dirId)
1324
2051
  return dirId;
1325
- // Only assign ROOT_COLLECTION_ID when the parent matches the configured
1326
- // content root (e.g., "content"). Other unrecognized paths are filtered out.
1327
- if (path.basename(f) === '.collection.json' && parentDir === this.contentRoot) {
2052
+ // Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
2053
+ // configured content root. Other unrecognized paths are filtered out.
2054
+ const parentPath = normalizeFilesystemPath(path.dirname(f));
2055
+ if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
1328
2056
  return ROOT_COLLECTION_ID;
1329
2057
  }
1330
2058
  return null;
@@ -1332,7 +2060,7 @@ export class CmsWorker {
1332
2060
  .filter((id) => id !== null);
1333
2061
  const conflictIdsDeduped = [...new Set(conflictIds)];
1334
2062
  const hadConflicts = conflictIdsDeduped.length > 0;
1335
- console.log(hadConflicts
2063
+ workerLog(hadConflicts
1336
2064
  ? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
1337
2065
  : ` Rebased ${branchDir} successfully`);
1338
2066
  await meta.save({
@@ -1350,10 +2078,51 @@ export class CmsWorker {
1350
2078
  // in the summary. Branches already up to date `continue`d above and
1351
2079
  // are deliberately not listed here.
1352
2080
  rebased.push(branchDir);
2081
+ // [SYNC-H1] The rebase just rewrote this clone's history. If that
2082
+ // history was already published, nothing else will ever reconcile
2083
+ // remote.git (and GitHub) with it -- the editor's next submit would
2084
+ // simply be rejected non-fast-forward. Carry the rewrite forward.
2085
+ if (publishedSha !== null) {
2086
+ if (publishedSha === preRebaseHead) {
2087
+ // ARMING GUARD. remote.git holds EXACTLY what this clone just
2088
+ // rebased away and nothing more, so a lease keyed to it can only
2089
+ // undo our own rewrite.
2090
+ //
2091
+ // The inequality case below is not defensive padding: branch
2092
+ // clones never fetch their own branch (GitManager's clone is
2093
+ // --single-branch and checkoutBranch only checks out an existing
2094
+ // local branch), while reconcileTrackedBranches fast-forwards
2095
+ // remote.git to GitHub's tip. So after a reviewer pushes a fixup
2096
+ // straight to the PR branch, remote.git legitimately holds a
2097
+ // commit this clone has never seen. Leasing on "whatever
2098
+ // remote.git currently holds" would be SATISFIED there and would
2099
+ // delete that fixup from remote.git and then from GitHub,
2100
+ // silently. Keying the lease to the pre-rebase tip turns that
2101
+ // case into the visible divergence it should be.
2102
+ //
2103
+ // Order matters: mark, then push, then queue. A crash after any
2104
+ // step leaves the marker set with the work unfinished, which
2105
+ // reconcilePendingRewrite() completes on a later cycle. Pushing
2106
+ // first would leave remote.git rewritten, GitHub stale and
2107
+ // nothing recorded -- unrecoverable, and landing on exactly the
2108
+ // false "another deployment" diagnosis this change removes.
2109
+ await this.markHistoryRewritten(branchPath, branchDir, publishedSha);
2110
+ if (await this.forcePublishToLocalRemote(branchPath, branchRef, publishedSha)) {
2111
+ await this.enqueueGitHubPush(branchRef);
2112
+ }
2113
+ }
2114
+ else {
2115
+ const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
2116
+ `clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
2117
+ `before submitting again.`;
2118
+ workerLogWarn(` ${branchDir}: ${divergence}`);
2119
+ await this.recordRebaseFailure(branchPath, branchDir, divergence);
2120
+ }
2121
+ }
1353
2122
  }
1354
2123
  catch (err) {
1355
2124
  const message = err instanceof Error ? err.message : 'Unknown error';
1356
- console.warn(` Failed to sync ${branchDir}: ${message}`);
2125
+ workerLogWarn(` Failed to sync ${branchDir}: ${message}`);
1357
2126
  // [HIGH-1] Same rationale as the `if (!completed)` push site above --
1358
2127
  // this catches fetch/rev-list/unexpected errors, whose message can
1359
2128
  // embed the bot token.
@@ -1370,14 +2139,14 @@ export class CmsWorker {
1370
2139
  async refreshAuthCache() {
1371
2140
  if (!this.running || !this.config.refreshAuthCache)
1372
2141
  return;
1373
- console.log('Refreshing auth cache...');
2142
+ workerLog('Refreshing auth cache...');
1374
2143
  try {
1375
2144
  await this.config.refreshAuthCache();
1376
- console.log('Auth cache refreshed');
2145
+ workerLog('Auth cache refreshed');
1377
2146
  }
1378
2147
  catch (err) {
1379
2148
  const message = err instanceof Error ? err.message : 'Unknown error';
1380
- console.error('Failed to refresh auth cache:', message);
2149
+ workerLogError('Failed to refresh auth cache:', message);
1381
2150
  }
1382
2151
  }
1383
2152
  }