canopycms 0.0.59 → 0.0.61-int.74

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 (286) hide show
  1. package/dist/api/__test__/mock-client.d.ts +29 -0
  2. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  3. package/dist/api/__test__/mock-client.js +55 -4
  4. package/dist/api/__test__/mock-client.js.map +1 -1
  5. package/dist/api/admin-branch-health.d.ts +65 -0
  6. package/dist/api/admin-branch-health.d.ts.map +1 -0
  7. package/dist/api/admin-branch-health.js +448 -0
  8. package/dist/api/admin-branch-health.js.map +1 -0
  9. package/dist/api/admin.d.ts +136 -0
  10. package/dist/api/admin.d.ts.map +1 -0
  11. package/dist/api/admin.js +338 -0
  12. package/dist/api/admin.js.map +1 -0
  13. package/dist/api/assets.d.ts.map +1 -1
  14. package/dist/api/assets.js +4 -0
  15. package/dist/api/assets.js.map +1 -1
  16. package/dist/api/branch-merge.d.ts +6 -0
  17. package/dist/api/branch-merge.d.ts.map +1 -1
  18. package/dist/api/branch-merge.js +15 -9
  19. package/dist/api/branch-merge.js.map +1 -1
  20. package/dist/api/branch-status.d.ts.map +1 -1
  21. package/dist/api/branch-status.js +15 -5
  22. package/dist/api/branch-status.js.map +1 -1
  23. package/dist/api/branch-withdraw.d.ts +6 -0
  24. package/dist/api/branch-withdraw.d.ts.map +1 -1
  25. package/dist/api/branch-withdraw.js +34 -7
  26. package/dist/api/branch-withdraw.js.map +1 -1
  27. package/dist/api/branch.d.ts +11 -1
  28. package/dist/api/branch.d.ts.map +1 -1
  29. package/dist/api/branch.js +70 -4
  30. package/dist/api/branch.js.map +1 -1
  31. package/dist/api/client.d.ts +34 -0
  32. package/dist/api/client.d.ts.map +1 -1
  33. package/dist/api/client.js +47 -0
  34. package/dist/api/client.js.map +1 -1
  35. package/dist/api/comments.d.ts.map +1 -1
  36. package/dist/api/comments.js +4 -0
  37. package/dist/api/comments.js.map +1 -1
  38. package/dist/api/content.d.ts.map +1 -1
  39. package/dist/api/content.js +4 -4
  40. package/dist/api/content.js.map +1 -1
  41. package/dist/api/entries.d.ts.map +1 -1
  42. package/dist/api/entries.js +23 -4
  43. package/dist/api/entries.js.map +1 -1
  44. package/dist/api/github-sync.d.ts.map +1 -1
  45. package/dist/api/github-sync.js +14 -3
  46. package/dist/api/github-sync.js.map +1 -1
  47. package/dist/api/groups.d.ts +3 -0
  48. package/dist/api/groups.d.ts.map +1 -1
  49. package/dist/api/groups.js +89 -68
  50. package/dist/api/groups.js.map +1 -1
  51. package/dist/api/guards.d.ts +7 -1
  52. package/dist/api/guards.d.ts.map +1 -1
  53. package/dist/api/guards.js +54 -0
  54. package/dist/api/guards.js.map +1 -1
  55. package/dist/api/index.d.ts +1 -0
  56. package/dist/api/index.d.ts.map +1 -1
  57. package/dist/api/index.js +0 -1
  58. package/dist/api/index.js.map +1 -1
  59. package/dist/api/permissions.d.ts +1 -0
  60. package/dist/api/permissions.d.ts.map +1 -1
  61. package/dist/api/permissions.js +23 -20
  62. package/dist/api/permissions.js.map +1 -1
  63. package/dist/api/schema.d.ts.map +1 -1
  64. package/dist/api/schema.js +36 -22
  65. package/dist/api/schema.js.map +1 -1
  66. package/dist/authorization/branch.d.ts +14 -1
  67. package/dist/authorization/branch.d.ts.map +1 -1
  68. package/dist/authorization/branch.js +5 -3
  69. package/dist/authorization/branch.js.map +1 -1
  70. package/dist/authorization/groups/index.d.ts +1 -1
  71. package/dist/authorization/groups/index.d.ts.map +1 -1
  72. package/dist/authorization/groups/index.js +1 -1
  73. package/dist/authorization/groups/index.js.map +1 -1
  74. package/dist/authorization/groups/loader.d.ts +25 -4
  75. package/dist/authorization/groups/loader.d.ts.map +1 -1
  76. package/dist/authorization/groups/loader.js +39 -20
  77. package/dist/authorization/groups/loader.js.map +1 -1
  78. package/dist/authorization/groups/schema.d.ts +8 -7
  79. package/dist/authorization/groups/schema.d.ts.map +1 -1
  80. package/dist/authorization/groups/schema.js +8 -4
  81. package/dist/authorization/groups/schema.js.map +1 -1
  82. package/dist/authorization/index.d.ts +5 -3
  83. package/dist/authorization/index.d.ts.map +1 -1
  84. package/dist/authorization/index.js +8 -3
  85. package/dist/authorization/index.js.map +1 -1
  86. package/dist/authorization/path.d.ts +8 -0
  87. package/dist/authorization/path.d.ts.map +1 -1
  88. package/dist/authorization/path.js +16 -1
  89. package/dist/authorization/path.js.map +1 -1
  90. package/dist/authorization/permissions/index.d.ts +1 -1
  91. package/dist/authorization/permissions/index.d.ts.map +1 -1
  92. package/dist/authorization/permissions/index.js +1 -1
  93. package/dist/authorization/permissions/index.js.map +1 -1
  94. package/dist/authorization/permissions/loader.d.ts +18 -4
  95. package/dist/authorization/permissions/loader.d.ts.map +1 -1
  96. package/dist/authorization/permissions/loader.js +35 -24
  97. package/dist/authorization/permissions/loader.js.map +1 -1
  98. package/dist/authorization/permissions/schema.d.ts +8 -7
  99. package/dist/authorization/permissions/schema.d.ts.map +1 -1
  100. package/dist/authorization/permissions/schema.js +8 -4
  101. package/dist/authorization/permissions/schema.js.map +1 -1
  102. package/dist/authorization/protected-branch.d.ts +39 -0
  103. package/dist/authorization/protected-branch.d.ts.map +1 -0
  104. package/dist/authorization/protected-branch.js +43 -0
  105. package/dist/authorization/protected-branch.js.map +1 -0
  106. package/dist/authorization/settings-file-store.d.ts +127 -0
  107. package/dist/authorization/settings-file-store.d.ts.map +1 -0
  108. package/dist/authorization/settings-file-store.js +156 -0
  109. package/dist/authorization/settings-file-store.js.map +1 -0
  110. package/dist/branch-health.d.ts +78 -0
  111. package/dist/branch-health.d.ts.map +1 -0
  112. package/dist/branch-health.js +151 -0
  113. package/dist/branch-health.js.map +1 -0
  114. package/dist/branch-metadata.d.ts +31 -0
  115. package/dist/branch-metadata.d.ts.map +1 -1
  116. package/dist/branch-metadata.js +43 -1
  117. package/dist/branch-metadata.js.map +1 -1
  118. package/dist/branch-registry.d.ts.map +1 -1
  119. package/dist/branch-registry.js +13 -1
  120. package/dist/branch-registry.js.map +1 -1
  121. package/dist/cli/cli.js +398 -200
  122. package/dist/cli/generate-ai-content.js +72 -13
  123. package/dist/cli/init.js +84 -2
  124. package/dist/cli/migrate.d.ts.map +1 -1
  125. package/dist/cli/migrate.js +39 -11
  126. package/dist/cli/migrate.js.map +1 -1
  127. package/dist/cli/sync.d.ts.map +1 -1
  128. package/dist/cli/sync.js +4 -1
  129. package/dist/cli/sync.js.map +1 -1
  130. package/dist/config/index.d.ts +1 -1
  131. package/dist/config/index.d.ts.map +1 -1
  132. package/dist/config/index.js.map +1 -1
  133. package/dist/config/schemas/config.d.ts +55 -5
  134. package/dist/config/schemas/config.d.ts.map +1 -1
  135. package/dist/config/schemas/config.js +18 -1
  136. package/dist/config/schemas/config.js.map +1 -1
  137. package/dist/config/types.d.ts +11 -1
  138. package/dist/config/types.d.ts.map +1 -1
  139. package/dist/content-store.d.ts +16 -0
  140. package/dist/content-store.d.ts.map +1 -1
  141. package/dist/content-store.js +22 -6
  142. package/dist/content-store.js.map +1 -1
  143. package/dist/context.d.ts +3 -0
  144. package/dist/context.d.ts.map +1 -1
  145. package/dist/context.js +25 -3
  146. package/dist/context.js.map +1 -1
  147. package/dist/editor/BranchManager.d.ts +11 -0
  148. package/dist/editor/BranchManager.d.ts.map +1 -1
  149. package/dist/editor/BranchManager.js +34 -10
  150. package/dist/editor/BranchManager.js.map +1 -1
  151. package/dist/editor/CommentsPanel.d.ts.map +1 -1
  152. package/dist/editor/CommentsPanel.js +4 -12
  153. package/dist/editor/CommentsPanel.js.map +1 -1
  154. package/dist/editor/Editor.d.ts.map +1 -1
  155. package/dist/editor/Editor.js +46 -10
  156. package/dist/editor/Editor.js.map +1 -1
  157. package/dist/editor/EntryNavigator.d.ts +7 -0
  158. package/dist/editor/EntryNavigator.d.ts.map +1 -1
  159. package/dist/editor/EntryNavigator.js +17 -5
  160. package/dist/editor/EntryNavigator.js.map +1 -1
  161. package/dist/editor/FormRenderer.d.ts.map +1 -1
  162. package/dist/editor/FormRenderer.js +4 -0
  163. package/dist/editor/FormRenderer.js.map +1 -1
  164. package/dist/editor/admin/SystemHealthPanel.d.ts +6 -0
  165. package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -0
  166. package/dist/editor/admin/SystemHealthPanel.js +234 -0
  167. package/dist/editor/admin/SystemHealthPanel.js.map +1 -0
  168. package/dist/editor/admin/useSystemHealth.d.ts +40 -0
  169. package/dist/editor/admin/useSystemHealth.d.ts.map +1 -0
  170. package/dist/editor/admin/useSystemHealth.js +205 -0
  171. package/dist/editor/admin/useSystemHealth.js.map +1 -0
  172. package/dist/editor/comments/FieldWrapper.d.ts.map +1 -1
  173. package/dist/editor/comments/FieldWrapper.js +1 -1
  174. package/dist/editor/comments/FieldWrapper.js.map +1 -1
  175. package/dist/editor/comments/InlineCommentThread.d.ts.map +1 -1
  176. package/dist/editor/comments/InlineCommentThread.js +2 -21
  177. package/dist/editor/comments/InlineCommentThread.js.map +1 -1
  178. package/dist/editor/comments/ThreadCarousel.d.ts +2 -0
  179. package/dist/editor/comments/ThreadCarousel.d.ts.map +1 -1
  180. package/dist/editor/comments/ThreadCarousel.js +5 -2
  181. package/dist/editor/comments/ThreadCarousel.js.map +1 -1
  182. package/dist/editor/components/EditorHeader.d.ts +11 -0
  183. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  184. package/dist/editor/components/EditorHeader.js +75 -41
  185. package/dist/editor/components/EditorHeader.js.map +1 -1
  186. package/dist/editor/components/EditorSidebar.d.ts +7 -1
  187. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  188. package/dist/editor/components/EditorSidebar.js +3 -3
  189. package/dist/editor/components/EditorSidebar.js.map +1 -1
  190. package/dist/editor/fields/ImageField.d.ts.map +1 -1
  191. package/dist/editor/fields/ImageField.js +4 -1
  192. package/dist/editor/fields/ImageField.js.map +1 -1
  193. package/dist/editor/fields/StringListField.d.ts +20 -0
  194. package/dist/editor/fields/StringListField.d.ts.map +1 -0
  195. package/dist/editor/fields/StringListField.js +25 -0
  196. package/dist/editor/fields/StringListField.js.map +1 -0
  197. package/dist/editor/hooks/useBranchActions.d.ts.map +1 -1
  198. package/dist/editor/hooks/useBranchActions.js +8 -2
  199. package/dist/editor/hooks/useBranchActions.js.map +1 -1
  200. package/dist/editor/hooks/useBranchManager.d.ts +14 -3
  201. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  202. package/dist/editor/hooks/useBranchManager.js +18 -1
  203. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  204. package/dist/editor/hooks/useDraftManager.d.ts +7 -0
  205. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  206. package/dist/editor/hooks/useDraftManager.js +57 -3
  207. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  208. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  209. package/dist/editor/hooks/useEntryManager.js +25 -7
  210. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  211. package/dist/editor/hooks/useGroupManager.d.ts.map +1 -1
  212. package/dist/editor/hooks/useGroupManager.js +13 -2
  213. package/dist/editor/hooks/useGroupManager.js.map +1 -1
  214. package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
  215. package/dist/editor/hooks/usePermissionManager.js +13 -2
  216. package/dist/editor/hooks/usePermissionManager.js.map +1 -1
  217. package/dist/editor/media/AssetCard.d.ts.map +1 -1
  218. package/dist/editor/media/AssetCard.js +23 -2
  219. package/dist/editor/media/AssetCard.js.map +1 -1
  220. package/dist/editor/relative-time.d.ts +8 -0
  221. package/dist/editor/relative-time.d.ts.map +1 -0
  222. package/dist/editor/relative-time.js +26 -0
  223. package/dist/editor/relative-time.js.map +1 -0
  224. package/dist/git-manager.d.ts.map +1 -1
  225. package/dist/git-manager.js +16 -2
  226. package/dist/git-manager.js.map +1 -1
  227. package/dist/http/handler.d.ts.map +1 -1
  228. package/dist/http/handler.js +20 -9
  229. package/dist/http/handler.js.map +1 -1
  230. package/dist/http/router.d.ts.map +1 -1
  231. package/dist/http/router.js +2 -0
  232. package/dist/http/router.js.map +1 -1
  233. package/dist/paths/branch-name.d.ts +20 -0
  234. package/dist/paths/branch-name.d.ts.map +1 -0
  235. package/dist/paths/branch-name.js +24 -0
  236. package/dist/paths/branch-name.js.map +1 -0
  237. package/dist/paths/branch.d.ts +2 -8
  238. package/dist/paths/branch.d.ts.map +1 -1
  239. package/dist/paths/branch.js +6 -12
  240. package/dist/paths/branch.js.map +1 -1
  241. package/dist/paths/index.d.ts +2 -1
  242. package/dist/paths/index.d.ts.map +1 -1
  243. package/dist/paths/index.js +4 -1
  244. package/dist/paths/index.js.map +1 -1
  245. package/dist/schema/schema-store.d.ts +98 -0
  246. package/dist/schema/schema-store.d.ts.map +1 -1
  247. package/dist/schema/schema-store.js +187 -29
  248. package/dist/schema/schema-store.js.map +1 -1
  249. package/dist/services.js +9 -1
  250. package/dist/services.js.map +1 -1
  251. package/dist/task-queue/index.d.ts +2 -2
  252. package/dist/task-queue/index.d.ts.map +1 -1
  253. package/dist/task-queue/index.js +1 -1
  254. package/dist/task-queue/index.js.map +1 -1
  255. package/dist/task-queue/task-queue.d.ts +37 -1
  256. package/dist/task-queue/task-queue.d.ts.map +1 -1
  257. package/dist/task-queue/task-queue.js +104 -0
  258. package/dist/task-queue/task-queue.js.map +1 -1
  259. package/dist/task-queue/types.d.ts +11 -0
  260. package/dist/task-queue/types.d.ts.map +1 -1
  261. package/dist/types.d.ts +54 -0
  262. package/dist/types.d.ts.map +1 -1
  263. package/dist/utils/occ-json-write.d.ts.map +1 -1
  264. package/dist/utils/occ-json-write.js +7 -0
  265. package/dist/utils/occ-json-write.js.map +1 -1
  266. package/dist/utils/provisioning-lock.d.ts +19 -0
  267. package/dist/utils/provisioning-lock.d.ts.map +1 -1
  268. package/dist/utils/provisioning-lock.js +26 -0
  269. package/dist/utils/provisioning-lock.js.map +1 -1
  270. package/dist/validation/reference-validator.d.ts +17 -1
  271. package/dist/validation/reference-validator.d.ts.map +1 -1
  272. package/dist/validation/reference-validator.js +33 -10
  273. package/dist/validation/reference-validator.js.map +1 -1
  274. package/dist/worker/cms-worker.d.ts +74 -0
  275. package/dist/worker/cms-worker.d.ts.map +1 -1
  276. package/dist/worker/cms-worker.js +542 -29
  277. package/dist/worker/cms-worker.js.map +1 -1
  278. package/dist/worker/task-queue.d.ts +3 -3
  279. package/dist/worker/task-queue.d.ts.map +1 -1
  280. package/dist/worker/task-queue.js +2 -2
  281. package/dist/worker/task-queue.js.map +1 -1
  282. package/dist/worker/worker-status.d.ts +48 -0
  283. package/dist/worker/worker-status.d.ts.map +1 -0
  284. package/dist/worker/worker-status.js +53 -0
  285. package/dist/worker/worker-status.js.map +1 -0
  286. package/package.json +1 -1
@@ -4,14 +4,39 @@ import { simpleGit } from 'simple-git';
4
4
  import lockfile from 'proper-lockfile';
5
5
  import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
6
6
  import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
7
- import { getBranchMetadataFileManager, BranchMetadataFileManager } from '../branch-metadata.js';
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 { getErrorMessage, isNodeError } from '../utils/error.js';
11
+ import { sanitizeBranchName } from '../paths/branch-name.js';
12
+ import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
13
+ import { writeWorkerStatus } from './worker-status.js';
12
14
  const DEFAULT_TASK_TIMEOUT = 60_000;
13
15
  const DEFAULT_MAX_RETRIES = 3;
14
16
  const DEFAULT_LOCK_STALE_MS = 60_000;
17
+ /**
18
+ * [C1] Retention window for `.trash-*` branch directories left behind by the
19
+ * admin purge action (api/admin-branch-health.ts). Matches
20
+ * cleanupOldTasks's default task retention for consistency.
21
+ */
22
+ const TRASH_RETENTION_MS = 30 * 24 * 60 * 60_000;
23
+ /** Matches `.trash-{dirName}-{STAMP}` names, capturing the trailing stamp. */
24
+ const TRASH_DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
25
+ /**
26
+ * Parse a purge-generated `YYYYMMDDTHHMMSSZ` stamp into a Date, or null if
27
+ * malformed. Age comes ONLY from this name-embedded stamp, never the dir's
28
+ * own mtime -- `fs.rename` preserves the original directory's mtime, so an
29
+ * mtime-based retention check would delete a months-stale orphan's trash on
30
+ * the very first cleanup pass after purge.
31
+ */
32
+ function parseTrashStamp(stamp) {
33
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
34
+ if (!match)
35
+ return null;
36
+ const [, year, month, day, hour, minute, second] = match;
37
+ const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
38
+ return Number.isNaN(date.getTime()) ? null : date;
39
+ }
15
40
  /**
16
41
  * An error inherent to the task itself (malformed payload, unknown action):
17
42
  * retrying can never succeed, so the task should fail fast instead of
@@ -135,6 +160,7 @@ export class CmsWorker {
135
160
  this.remoteGitPath = path.join(config.workspacePath, 'remote.git');
136
161
  this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
137
162
  this.baseBranch = config.baseBranch ?? 'main';
163
+ this.sanitizedBaseBranch = sanitizeBranchName(this.baseBranch);
138
164
  this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
139
165
  this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
140
166
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
@@ -142,9 +168,25 @@ export class CmsWorker {
142
168
  this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
143
169
  this.contentRoot = config.contentRoot ?? 'content';
144
170
  }
171
+ /**
172
+ * Lazily get (and initialize if necessary) this worker's self-reported
173
+ * status object (PR-W1). Normally set once, up front, at the top of
174
+ * start(). The lazy fallback here covers two cases: (1) something in
175
+ * start() reaching a status-write point before that normal init runs
176
+ * (defensive -- see start()'s catch), and (2) unit tests that exercise
177
+ * syncGit()/processTaskQueue() directly without calling start() first.
178
+ */
179
+ ensureStatusReport() {
180
+ if (!this.statusReport) {
181
+ const now = new Date().toISOString();
182
+ this.statusReport = { version: 1, startedAt: now, updatedAt: now };
183
+ }
184
+ return this.statusReport;
185
+ }
145
186
  async start() {
146
187
  this.running = true;
147
188
  console.log('CMS Worker starting...');
189
+ this.ensureStatusReport();
148
190
  // Acquire lock to prevent concurrent workers
149
191
  await this.acquireLock();
150
192
  // Everything below runs while holding the cross-host worker lock. A
@@ -175,6 +217,25 @@ export class CmsWorker {
175
217
  await Promise.allSettled(initialTasks);
176
218
  }
177
219
  catch (err) {
220
+ // PR-W1: surface a startup failure (e.g. the empty-remote guard's
221
+ // poisoned remote.git) to the admin panel via worker-status.json,
222
+ // not only journald/CloudWatch. Best-effort and BEFORE releaseLock():
223
+ // a status-write failure must never block releasing the lock.
224
+ const report = this.ensureStatusReport();
225
+ report.lastFatalError = {
226
+ // [HIGH-1] Persisted to worker-status.json and served to the
227
+ // browser by the admin panel -- must never carry the bot token
228
+ // that a poisoned/failed git URL (buildGitHubUrl()) can embed.
229
+ message: redactCredentials(getErrorMessage(err)),
230
+ at: new Date().toISOString(),
231
+ phase: 'startup',
232
+ };
233
+ try {
234
+ await writeWorkerStatus(this.taskDir, report);
235
+ }
236
+ catch (writeErr) {
237
+ console.error('Failed to write worker status on startup failure:', getErrorMessage(writeErr));
238
+ }
178
239
  await this.releaseLock();
179
240
  throw err;
180
241
  }
@@ -396,6 +457,11 @@ export class CmsWorker {
396
457
  catch (err) {
397
458
  const message = getErrorMessage(err);
398
459
  console.error(`Task ${task.id} (${task.action}) failed:`, message);
460
+ // [HIGH-1] task.error is persisted (pending/failed task JSON) and
461
+ // served to the browser by the admin panel's Tasks tab -- a push
462
+ // failure's message can embed the bot token via buildGitHubUrl().
463
+ // Console output above stays raw (journald/CloudWatch is trusted).
464
+ const persistedMessage = redactCredentials(message);
399
465
  // DEP-L1: only transient failures (network, 429/5xx, timeouts) are
400
466
  // worth retrying; permanent ones (malformed payload, other 4xx) would
401
467
  // just burn the retry budget on an identical doomed request.
@@ -403,12 +469,12 @@ export class CmsWorker {
403
469
  const retryCount = task.retryCount ?? 0;
404
470
  const maxRetries = task.maxRetries ?? this.maxRetries;
405
471
  if (!permanent && retryCount < maxRetries) {
406
- await retryTask(this.taskDir, task.id, message, this.log);
472
+ await retryTask(this.taskDir, task.id, persistedMessage, this.log);
407
473
  console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
408
474
  }
409
475
  else {
410
- await failTask(this.taskDir, task.id, message, this.log);
411
- await this.updateBranchMetadataOnFailure(task, message);
476
+ await failTask(this.taskDir, task.id, persistedMessage, this.log);
477
+ await this.updateBranchMetadataOnFailure(task, persistedMessage);
412
478
  console.error(permanent
413
479
  ? ' Permanently failed (non-retryable error)'
414
480
  : ` Permanently failed after ${maxRetries} retries`);
@@ -416,6 +482,15 @@ export class CmsWorker {
416
482
  }
417
483
  processed++;
418
484
  }
485
+ // PR-W1: only stamp/write when work actually happened this poll --
486
+ // otherwise every idle 5s poll would hit worker-status.json, an EFS
487
+ // write treadmill for no signal (liveness is already covered by the
488
+ // lock heartbeat; see api/admin.ts's classifyWorkerLiveness).
489
+ if (processed > 0) {
490
+ const report = this.ensureStatusReport();
491
+ report.lastTaskCycleAt = new Date().toISOString();
492
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
493
+ }
419
494
  }
420
495
  /**
421
496
  * Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
@@ -491,13 +566,23 @@ export class CmsWorker {
491
566
  // direct-API path) so the list->tiebreak->update/create logic and
492
567
  // the draft->ready conversion live in one place.
493
568
  const branch = requireString(payload, 'branch');
569
+ const base = optionalString(payload, 'baseBranch', this.baseBranch);
570
+ // Defense-in-depth: refuse head===base even if the 'submittableBranch'
571
+ // API guard and the syncSubmitPr backstop were both somehow bypassed
572
+ // (e.g. a task queued before this check shipped). PermanentTaskError
573
+ // (not a plain Error) so this fails immediately instead of burning
574
+ // the retry budget on an identical doomed request -- retrying can
575
+ // never make the branch not be the base branch.
576
+ if (sanitizeBranchName(branch) === this.sanitizedBaseBranch) {
577
+ 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`);
578
+ }
494
579
  await this.pushBranchToGitHub(branch);
495
580
  const result = await createOrUpdatePullRequest({
496
581
  octokit: this.octokit,
497
582
  owner: this.config.githubOwner,
498
583
  repo: this.config.githubRepo,
499
584
  head: branch,
500
- base: optionalString(payload, 'baseBranch', this.baseBranch),
585
+ base,
501
586
  title: optionalString(payload, 'title', `Submit ${branch}`),
502
587
  body: optionalString(payload, 'body', ''),
503
588
  // Content submits (api/github-sync.ts) set this; settings-branch
@@ -572,8 +657,21 @@ export class CmsWorker {
572
657
  };
573
658
  if (result.prUrl)
574
659
  updates.pullRequestUrl = result.prUrl;
575
- if (result.prNumber)
660
+ if (result.prNumber) {
576
661
  updates.pullRequestNumber = result.prNumber;
662
+ // As soon as the PR exists the field should read 'open' -- without
663
+ // this it would stay absent until the next poll cycle observes it.
664
+ // Exception: the git-sync loop may have archived this branch (PR
665
+ // merged/closed) while this task was in flight; a late task
666
+ // completion must not downgrade that terminal state back to 'open'.
667
+ const current = await BranchMetadataFileManager.loadOnly(branchPath);
668
+ const terminal = current?.branch.status === 'archived' ||
669
+ current?.branch.pullRequestState === 'merged' ||
670
+ current?.branch.pullRequestState === 'closed';
671
+ if (!terminal) {
672
+ updates.pullRequestState = 'open';
673
+ }
674
+ }
577
675
  await meta.save({ branch: updates });
578
676
  }
579
677
  catch (err) {
@@ -645,6 +743,7 @@ export class CmsWorker {
645
743
  if (!this.running)
646
744
  return;
647
745
  console.log('Syncing git...');
746
+ const cycleStartedAt = Date.now();
648
747
  const git = simpleGit({
649
748
  baseDir: this.remoteGitPath,
650
749
  // DEP-H1: a hung fetch/push would stall the sync loop forever
@@ -652,35 +751,384 @@ export class CmsWorker {
652
751
  // is inactivity-based, so a slow-but-flowing transfer is unaffected.
653
752
  timeout: { block: this.taskTimeoutMs },
654
753
  });
655
- // Fetch all branches from GitHub using direct URL (no named remote)
656
- // We use raw git commands since simple-git's fetch() with a URL
657
- // doesn't support --prune directly
658
- await git.raw(['fetch', this.buildGitHubUrl(), '--prune', '+refs/heads/*:refs/heads/*']);
659
- console.log('Fetched from GitHub');
660
- // Push settings branches to GitHub (belt-and-suspenders for task queue).
661
- // Ensures settings reach GitHub even if a task queue entry is lost.
662
- await this.pushSettingsBranches(git);
663
- await this.rebaseActiveBranches();
664
- // Periodically clean up old completed/failed tasks
665
- await cleanupOldTasks(this.taskDir, undefined, this.log);
754
+ // PR-W1: the whole cycle is wrapped so both outcomes -- success and
755
+ // hard failure (e.g. the fetch throwing against a poisoned remote.git)
756
+ // -- record a worker-status.json snapshot. The status write itself is
757
+ // always best-effort (.catch below): it must never turn an otherwise
758
+ // successful cycle into a failure, and must never mask the real error
759
+ // on a failed one. On failure we rethrow so scheduleLoop's existing
760
+ // per-cycle catch stays the loud path.
761
+ 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');
767
+ // Push settings branches to GitHub (belt-and-suspenders for task queue).
768
+ // Ensures settings reach GitHub even if a task queue entry is lost.
769
+ await this.pushSettingsBranches(git);
770
+ await this.refreshBaseBranchWorkspace();
771
+ const rebaseSummary = await this.rebaseActiveBranches();
772
+ // Periodically clean up old completed/failed tasks
773
+ await cleanupOldTasks(this.taskDir, undefined, this.log);
774
+ // [C1] Sweep branch directories the admin purge action trashed more
775
+ // than TRASH_RETENTION_MS ago. Worker-only by design: purge itself
776
+ // never deletes anything (reversible), and this cycle is the sole
777
+ // place actual removal happens.
778
+ const trashRemoved = await this.cleanupTrashedBranchDirs();
779
+ if (trashRemoved > 0) {
780
+ console.log(`Removed ${trashRemoved} expired trashed branch dir(s)`);
781
+ }
782
+ const report = this.ensureStatusReport();
783
+ report.lastGitSyncAt = new Date().toISOString();
784
+ delete report.lastGitSyncError;
785
+ report.lastGitSync = {
786
+ durationMs: Date.now() - cycleStartedAt,
787
+ rebased: rebaseSummary.rebased,
788
+ skippedDirty: rebaseSummary.skippedDirty,
789
+ failed: rebaseSummary.failed,
790
+ };
791
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
792
+ }
793
+ catch (err) {
794
+ const report = this.ensureStatusReport();
795
+ // [HIGH-1] Persisted to worker-status.json and served to the browser
796
+ // by the admin panel -- a fetch/push failure's message can embed the
797
+ // bot token via buildGitHubUrl().
798
+ report.lastGitSyncError = {
799
+ message: redactCredentials(getErrorMessage(err)),
800
+ at: new Date().toISOString(),
801
+ };
802
+ await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
803
+ throw err;
804
+ }
805
+ }
806
+ /**
807
+ * [C1] Remove `.trash-*` branch directories (created by the admin purge
808
+ * action, api/admin-branch-health.ts) whose name-embedded stamp is older
809
+ * than {@link TRASH_RETENTION_MS}. Names that don't match the expected
810
+ * `.trash-{dirName}-{STAMP}` shape, or whose stamp fails to parse, are
811
+ * left alone (logged once per cycle, not per file, to avoid flooding logs
812
+ * if something odd accumulates) -- purge is the only writer of this
813
+ * naming scheme, so an unparseable name is unexpected and worth a human
814
+ * looking rather than a silent skip.
815
+ */
816
+ async cleanupTrashedBranchDirs() {
817
+ let entries;
818
+ try {
819
+ entries = await fs.readdir(this.contentBranchesPath);
820
+ }
821
+ catch (err) {
822
+ if (isNodeError(err) && err.code === 'ENOENT')
823
+ return 0;
824
+ throw err;
825
+ }
826
+ const now = Date.now();
827
+ let removed = 0;
828
+ let loggedUnparseable = false;
829
+ for (const name of entries) {
830
+ if (!name.startsWith('.trash-'))
831
+ continue;
832
+ const stampMatch = TRASH_DIR_STAMP_RE.exec(name);
833
+ const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
834
+ if (!stampDate) {
835
+ if (!loggedUnparseable) {
836
+ console.log(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
837
+ loggedUnparseable = true;
838
+ }
839
+ continue;
840
+ }
841
+ if (now - stampDate.getTime() < TRASH_RETENTION_MS)
842
+ continue;
843
+ try {
844
+ await fs.rm(path.join(this.contentBranchesPath, name), {
845
+ recursive: true,
846
+ force: true,
847
+ maxRetries: 3,
848
+ retryDelay: 100,
849
+ });
850
+ removed++;
851
+ }
852
+ catch (err) {
853
+ console.error(`CanopyCMS: Failed to remove trashed branch dir ${name}:`, getErrorMessage(err));
854
+ }
855
+ }
856
+ return removed;
857
+ }
858
+ /**
859
+ * Fast-forward the base branch's own working-tree clone
860
+ * (content-branches/<baseBranch>) to match origin/<baseBranch>.
861
+ *
862
+ * Previously this clone was refreshed only incidentally, by the generic
863
+ * rebase loop below (rebaseActiveBranches): for a branch with status
864
+ * 'editing', rebasing onto origin/<baseBranch> degenerates to a
865
+ * fast-forward when the clone IS the base branch. But that loop's skip
866
+ * paths -- a dirty tree, a missing .git -- are silent, which is the
867
+ * suspected live failure mode: a wedged base clone with no diagnosable
868
+ * signal in the logs. This dedicated step makes the refresh explicit,
869
+ * ff-only, and loud, so a stuck base view (an editor forking a new branch
870
+ * "from base" that's actually a stale snapshot) is diagnosable from logs.
871
+ * This runs every sync cycle so the drift window is bounded by
872
+ * gitSyncInterval.
873
+ *
874
+ * ff-only on purpose: this clone must stay a linear mirror of
875
+ * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged
876
+ * local history) is treated as a should-never-happen condition and left
877
+ * untouched rather than force-resolved.
878
+ */
879
+ async refreshBaseBranchWorkspace() {
880
+ // Sanitized name for the workspace directory (a base branch containing
881
+ // e.g. '/' would otherwise stat a wrong nested path here forever).
882
+ const basePath = path.join(this.contentBranchesPath, this.sanitizedBaseBranch);
883
+ const gitDir = path.join(basePath, '.git');
884
+ try {
885
+ let gitDirStat;
886
+ try {
887
+ gitDirStat = await fs.stat(gitDir);
888
+ }
889
+ catch {
890
+ gitDirStat = null;
891
+ }
892
+ if (!gitDirStat || !gitDirStat.isDirectory()) {
893
+ console.log(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
894
+ return;
895
+ }
896
+ const baseGit = simpleGit({
897
+ baseDir: basePath,
898
+ // Keep git non-interactive during the merge so it never blocks on an
899
+ // editor. simple-git >=3.32 blocks setting core.editor unless
900
+ // explicitly opted in; the value here is a hardcoded literal
901
+ // ("true", the shell no-op), not user input, so enabling
902
+ // allowUnsafeEditor carries no injection risk (same as the rebase
903
+ // loop's git config below).
904
+ config: ['core.editor=true'],
905
+ unsafe: { allowUnsafeEditor: true },
906
+ // DEP-H1: a hung fetch/merge against this EFS-backed clone would
907
+ // stall the sync loop forever (scheduleLoop only reschedules after
908
+ // completion). The block timeout is inactivity-based, so a
909
+ // slow-but-flowing transfer is unaffected -- same as syncGit()'s
910
+ // remote handle.
911
+ timeout: { block: this.taskTimeoutMs },
912
+ });
913
+ // Nothing makes this clone read-only. A direct edit here (or a stray
914
+ // process) wedges every editor's view of the base branch until an
915
+ // operator intervenes, so a dirty tree is loud, not a quiet skip.
916
+ // Only TRACKED changes block the refresh: a stray untracked file (e.g.
917
+ // runtime metadata missing from .git/info/exclude) must not wedge the
918
+ // fast-forward forever — if an untracked file would collide with
919
+ // incoming content, the --ff-only merge below refuses on its own and
920
+ // that failure is already logged loudly.
921
+ const status = await baseGit.status();
922
+ const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
923
+ 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(', ')}`);
925
+ return;
926
+ }
927
+ // Raw (unsanitized) name from here on: these are git ref operations
928
+ // against origin/<baseBranch>, not filesystem paths, so they must use
929
+ // the same name GitHub knows the branch by.
930
+ await baseGit.fetch('origin', this.baseBranch);
931
+ // Use rev-list instead of status.behind for the same reason as the
932
+ // rebase loop below: status.behind only works with an upstream
933
+ // tracking branch configured, which isn't guaranteed here.
934
+ // Compare against the just-fetched tip rather than origin/<base>:
935
+ // workspaces are cloned --single-branch (git-manager.ts), so their
936
+ // fetch refspec only materializes a remote-tracking ref for the branch
937
+ // they were cloned from — for any other base branch, origin/<base>
938
+ // simply never exists and rev-list dies with "ambiguous argument".
939
+ // Pin FETCH_HEAD to a SHA immediately: FETCH_HEAD is one shared mutable
940
+ // file per repo, silently repointed by ANY other fetch in this clone.
941
+ const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
942
+ const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
943
+ if (behindCount > 0) {
944
+ try {
945
+ await baseGit.merge(['--ff-only', fetchedTip]);
946
+ }
947
+ catch (err) {
948
+ console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
949
+ return;
950
+ }
951
+ await invalidateBranchContentCaches(basePath);
952
+ }
953
+ // Hygiene: conflictStatus/conflictFiles are meaningless for the base
954
+ // branch's own metadata and may be left over from before the base was
955
+ // excluded from rebaseActiveBranches()'s conflict-resolution loop.
956
+ // Reuse the already-clean no-op-save guard from that loop: save()
957
+ // eager-regenerates the branch registry (O(branch count) EFS reads),
958
+ // so skip it when there's nothing to clear.
959
+ const currentMeta = await BranchMetadataFileManager.loadOnly(basePath);
960
+ const conflictStatus = currentMeta?.branch.conflictStatus;
961
+ const conflictFiles = currentMeta?.branch.conflictFiles;
962
+ const alreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
963
+ (conflictFiles === undefined || conflictFiles.length === 0);
964
+ if (!alreadyClean) {
965
+ const meta = getBranchMetadataFileManager(basePath, this.contentBranchesPath);
966
+ await meta.save({
967
+ branch: { name: this.baseBranch, conflictStatus: 'clean', conflictFiles: [] },
968
+ });
969
+ }
970
+ // One concise per-cycle line -- the diagnostic for the next live deploy.
971
+ console.log(behindCount > 0
972
+ ? `Base branch workspace (${this.baseBranch}): fast-forwarded ${behindCount} commit(s)`
973
+ : `Base branch workspace (${this.baseBranch}): up to date`);
974
+ }
975
+ catch (err) {
976
+ console.error(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
977
+ }
978
+ }
979
+ /**
980
+ * Poll GitHub for a submitted/approved branch's PR resolution.
981
+ *
982
+ * submitted/approved branches sit outside the rebase loop and get no
983
+ * other signal that their PR resolved on GitHub -- nothing pushes a
984
+ * merge/close webhook back into the branch workspace. merged ->
985
+ * auto-archive via buildMergedBranchUpdate (shared with the manual
986
+ * markAsMerged API so both paths produce identical archived-branch
987
+ * metadata). closed-without-merge -> record pullRequestState only; an
988
+ * admin decides the workflow transition from there. Best-effort: any
989
+ * failure here is logged and swallowed, retried next sync cycle.
990
+ */
991
+ async pollMergeState(branchDir, branchPath, metaFile) {
992
+ const prNumber = metaFile?.branch.pullRequestNumber;
993
+ if (!prNumber)
994
+ return;
995
+ const controller = new AbortController();
996
+ const timer = setTimeout(() => controller.abort(), this.taskTimeoutMs);
997
+ try {
998
+ const { data } = await this.octokit.pulls.get({
999
+ owner: this.config.githubOwner,
1000
+ repo: this.config.githubRepo,
1001
+ pull_number: prNumber,
1002
+ request: { signal: controller.signal },
1003
+ });
1004
+ if (data.merged) {
1005
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1006
+ // Use GitHub's actual merge time when available; buildMergedBranchUpdate
1007
+ // falls back to "now" (its default `now` param) when merged_at is absent.
1008
+ await meta.save({
1009
+ branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
1010
+ });
1011
+ console.log(` PR #${prNumber} for ${branchDir} is merged -> archived`);
1012
+ return;
1013
+ }
1014
+ const newState = data.state === 'closed' ? 'closed' : 'open';
1015
+ // Re-load fresh (not the loop-top `metaFile` snapshot passed in) -- a
1016
+ // concurrent Lambda write (e.g. an editor re-submitting) may have
1017
+ // landed since that snapshot was taken.
1018
+ const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
1019
+ if (currentMeta?.branch.pullRequestState === newState)
1020
+ return;
1021
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1022
+ await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
1023
+ console.log(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
1024
+ }
1025
+ catch (err) {
1026
+ // Non-fatal: transient GitHub/network errors are retried next cycle.
1027
+ console.warn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
1028
+ }
1029
+ finally {
1030
+ clearTimeout(timer);
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Persist a per-branch rebase failure to branch.json (PR-W2), bounded to
1035
+ * roughly one save per failing branch per hour: a branch stuck failing
1036
+ * every cycle must not turn into unbounded save-per-cycle x N-failing-
1037
+ * branches write amplification -- save() eager-regenerates the branch
1038
+ * registry (branch-metadata.ts's invalidateRegistry(), O(branch count) EFS
1039
+ * reads), the same concern the `alreadyClean` no-op guard above exists
1040
+ * for.
1041
+ *
1042
+ * Best-effort and non-fatal like every other metadata write in this
1043
+ * loop's error paths: a corrupt branch.json, a lock-contention error, or
1044
+ * any other save failure here must never abort the per-branch iteration.
1045
+ * This matters doubly at the two call sites -- one is inside the outer
1046
+ * per-branch catch, with no further catch of its own around this call --
1047
+ * so the whole method is wrapped, not just the load.
1048
+ */
1049
+ async recordRebaseFailure(branchPath, branchDir, message) {
1050
+ const RECORD_REFRESH_MS = 60 * 60 * 1000; // 1 hour
1051
+ // [HIGH-1] Defense-in-depth redaction: rebaseFailure.message is
1052
+ // persisted to branch.json and served to the browser via the
1053
+ // branch-health admin endpoint. Both call sites in rebaseActiveBranches
1054
+ // already redact before passing in (the failed.push sites below),
1055
+ // redactCredentials is idempotent, so redacting again here is free and
1056
+ // keeps this method safe on its own.
1057
+ const redactedMessage = redactCredentials(message);
1058
+ try {
1059
+ const existing = await BranchMetadataFileManager.loadOnly(branchPath);
1060
+ const prior = existing?.branch.rebaseFailure;
1061
+ const sameMessage = prior?.message === redactedMessage;
1062
+ const now = new Date();
1063
+ if (sameMessage) {
1064
+ const lastAtMs = Date.parse(prior.lastAt);
1065
+ if (!Number.isNaN(lastAtMs) && now.getTime() - lastAtMs < RECORD_REFRESH_MS) {
1066
+ // Same failure, refreshed within the last hour -- skip the save.
1067
+ return;
1068
+ }
1069
+ }
1070
+ const nowIso = now.toISOString();
1071
+ const firstAt = sameMessage ? prior.firstAt : nowIso;
1072
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1073
+ await meta.save({
1074
+ branch: {
1075
+ name: branchDir,
1076
+ rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso },
1077
+ },
1078
+ });
1079
+ }
1080
+ catch (err) {
1081
+ // Includes BranchMetadataCorruptError from the load above (a save()
1082
+ // against the same corrupt file would just throw again) as well as
1083
+ // any other load/save failure -- recording is best-effort
1084
+ // observability, never allowed to abort the branch loop.
1085
+ console.warn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
1086
+ }
666
1087
  }
667
1088
  async rebaseActiveBranches() {
1089
+ // PR-W1: collected across the loop below and returned as a summary
1090
+ // (folded into worker-status.json by syncGit()). Purely additive
1091
+ // bookkeeping -- doesn't change any control flow or existing logging.
1092
+ const rebased = [];
1093
+ const skippedDirty = [];
1094
+ const failed = [];
668
1095
  let branchDirs;
669
1096
  try {
670
1097
  branchDirs = await fs.readdir(this.contentBranchesPath);
671
1098
  }
672
1099
  catch {
673
- return;
1100
+ return { rebased, skippedDirty, failed };
674
1101
  }
675
1102
  for (const branchDir of branchDirs) {
1103
+ // Known structural entries under content-branches/, not branch
1104
+ // workspaces: branches.json (registry snapshot) and dot-prefixed
1105
+ // entries (.canopy-meta/, transient lock dirs). Skip them silently so
1106
+ // the no-.git skip logs below don't fire for them every cycle.
1107
+ // Everything else still logs loudly on skip.
1108
+ if (branchDir.startsWith('.') || branchDir === 'branches.json') {
1109
+ continue;
1110
+ }
676
1111
  const branchPath = path.join(this.contentBranchesPath, branchDir);
677
1112
  const gitDir = path.join(branchPath, '.git');
678
1113
  try {
679
1114
  const stat = await fs.stat(gitDir);
680
- if (!stat.isDirectory())
1115
+ if (!stat.isDirectory()) {
1116
+ console.log(` Skipping ${branchDir}: .git is not a directory`);
681
1117
  continue;
1118
+ }
682
1119
  }
683
1120
  catch {
1121
+ console.log(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
1122
+ continue;
1123
+ }
1124
+ // The base branch's own clone is refreshed ff-only by
1125
+ // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it
1126
+ // through this conflict-resolution rebase loop could rewrite its
1127
+ // history (the --theirs loop below) and stamp meaningless conflict
1128
+ // metadata on it. Compare sanitized-vs-sanitized: branchDir is a
1129
+ // filesystem name (already sanitized), this.baseBranch is raw.
1130
+ if (branchDir === this.sanitizedBaseBranch) {
1131
+ console.log(` Skipping ${branchDir}: base branch (refreshed separately)`);
684
1132
  continue;
685
1133
  }
686
1134
  try {
@@ -688,11 +1136,17 @@ export class CmsWorker {
688
1136
  const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
689
1137
  const branchStatus = metaFile?.branch.status;
690
1138
  // Skip branches that shouldn't be mutated:
691
- // - submitted/approved: in review, don't rewrite history under an active PR
692
- // - archived: already merged, no reason to rebase
693
- if (branchStatus === 'submitted' ||
694
- branchStatus === 'approved' ||
695
- branchStatus === 'archived') {
1139
+ // - submitted/approved: in review, don't rewrite history under an
1140
+ // active PR -- but do poll GitHub for the PR's resolution, since
1141
+ // nothing else tells the worker a merge/close happened.
1142
+ // - archived: already merged, no reason to rebase and no PR left to
1143
+ // poll (avoid the wasted API call).
1144
+ if (branchStatus === 'submitted' || branchStatus === 'approved') {
1145
+ console.log(` Skipping ${branchDir} (${branchStatus})`);
1146
+ await this.pollMergeState(branchDir, branchPath, metaFile);
1147
+ continue;
1148
+ }
1149
+ if (branchStatus === 'archived') {
696
1150
  console.log(` Skipping ${branchDir} (${branchStatus})`);
697
1151
  continue;
698
1152
  }
@@ -712,13 +1166,20 @@ export class CmsWorker {
712
1166
  const dirtyCheck = await branchGit.status();
713
1167
  if (dirtyCheck.files.length > 0) {
714
1168
  console.log(` Skipping ${branchDir}: has uncommitted changes`);
1169
+ skippedDirty.push(branchDir);
715
1170
  continue;
716
1171
  }
717
1172
  await branchGit.fetch('origin', this.baseBranch);
718
1173
  // Use rev-list instead of status.behind — status.behind only works when the
719
1174
  // branch has an upstream tracking branch configured, which isn't guaranteed
720
1175
  // (checkoutBranch fallback paths create branches without --track).
721
- const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..origin/${this.baseBranch}`])).trim(), 10);
1176
+ // The just-fetched tip, not origin/<base>: branch clones are
1177
+ // --single-branch, so no remote-tracking ref exists for a base branch
1178
+ // other than the one they were cloned from (see the base-refresh
1179
+ // comment above). Pinned to a SHA immediately — FETCH_HEAD is one
1180
+ // shared mutable file per repo, repointed by any concurrent fetch.
1181
+ const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
1182
+ const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
722
1183
  const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
723
1184
  if (behindCount === 0) {
724
1185
  // Already in sync. This is the overwhelmingly common outcome per
@@ -735,8 +1196,13 @@ export class CmsWorker {
735
1196
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
736
1197
  const conflictStatus = currentMeta?.branch.conflictStatus;
737
1198
  const conflictFiles = currentMeta?.branch.conflictFiles;
738
- const alreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
1199
+ const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
739
1200
  (conflictFiles === undefined || conflictFiles.length === 0);
1201
+ // PR-W2: a lingering rebaseFailure must also be cleared once the
1202
+ // branch catches up clean -- otherwise it sticks as a stale
1203
+ // warning forever (nothing else touches this branch once it's
1204
+ // caught up, so no other save site would ever clear it).
1205
+ const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
740
1206
  if (alreadyClean) {
741
1207
  continue;
742
1208
  }
@@ -745,6 +1211,7 @@ export class CmsWorker {
745
1211
  name: branchDir,
746
1212
  conflictStatus: 'clean',
747
1213
  conflictFiles: [],
1214
+ rebaseFailure: undefined,
748
1215
  },
749
1216
  });
750
1217
  continue;
@@ -755,11 +1222,16 @@ export class CmsWorker {
755
1222
  const conflictedFiles = [];
756
1223
  let nextAction = 'start';
757
1224
  let completed = false;
1225
+ // PR-W1: captured only on the "unexpected error" exit below, for the
1226
+ // failed-summary entry pushed at the `if (!completed)` check.
1227
+ let failureReason;
758
1228
  const MAX_ROUNDS = 50; // safety limit against infinite loops
759
1229
  for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
760
1230
  try {
761
1231
  if (nextAction === 'start') {
762
- await branchGit.rebase([`origin/${this.baseBranch}`]);
1232
+ // The pinned base tip fetched above (single-branch clones have
1233
+ // no origin/<base> remote-tracking ref for other branches).
1234
+ await branchGit.rebase([fetchedBaseTip]);
763
1235
  }
764
1236
  else if (nextAction === 'continue') {
765
1237
  await branchGit.rebase(['--continue']);
@@ -796,6 +1268,7 @@ export class CmsWorker {
796
1268
  // state. Previous metadata (possibly stale) is preserved until the
797
1269
  // next successful rebase cycle corrects it.
798
1270
  console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
1271
+ failureReason = msg || 'Unknown error';
799
1272
  await branchGit.rebase(['--abort']).catch(() => { });
800
1273
  break;
801
1274
  }
@@ -803,8 +1276,30 @@ export class CmsWorker {
803
1276
  }
804
1277
  }
805
1278
  if (!completed) {
806
- console.warn(` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
1279
+ // PR-W2 (M1 rider): failureReason is only set on the "unexpected
1280
+ // error" break above -- MAX_ROUNDS exhaustion is a distinct exit
1281
+ // path with no error message of its own, so the warn text must
1282
+ // not conflate the two.
1283
+ console.warn(failureReason !== undefined
1284
+ ? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
1285
+ : ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
807
1286
  await branchGit.rebase(['--abort']).catch(() => { });
1287
+ const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_ROUNDS} rounds`;
1288
+ // [HIGH-1] failed[] folds into worker-status.json's
1289
+ // lastGitSync.failed, served to the browser -- failureReason can
1290
+ // be an arbitrary git error message that embeds the bot token.
1291
+ const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
1292
+ failed.push({
1293
+ branch: branchDir,
1294
+ error: redactedRebaseFailureMessage,
1295
+ });
1296
+ // PR-W2: record once here for the "!completed" exit -- the
1297
+ // unexpected-error break above is NOT disjoint from this block (it
1298
+ // always falls through here), so recording at the break itself
1299
+ // would double-record. The outer catch below is the only other
1300
+ // record site (a distinct, non-overlapping failure class: errors
1301
+ // outside this round loop, e.g. fetch/rev-list failures).
1302
+ await this.recordRebaseFailure(branchPath, branchDir, redactedRebaseFailureMessage);
808
1303
  continue;
809
1304
  }
810
1305
  // The rebase rewrote the branch clone's working tree — mark ContentStore
@@ -845,14 +1340,32 @@ export class CmsWorker {
845
1340
  name: branchDir,
846
1341
  conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
847
1342
  conflictFiles: conflictIdsDeduped,
1343
+ // PR-W2: the cycle completed successfully -- clear any prior
1344
+ // failure record regardless of conflict outcome.
1345
+ rebaseFailure: undefined,
848
1346
  },
849
1347
  });
1348
+ // PR-W1: the branch was behind and the rebase completed (with or
1349
+ // without --theirs conflict resolution) -- it moved, so it belongs
1350
+ // in the summary. Branches already up to date `continue`d above and
1351
+ // are deliberately not listed here.
1352
+ rebased.push(branchDir);
850
1353
  }
851
1354
  catch (err) {
852
1355
  const message = err instanceof Error ? err.message : 'Unknown error';
853
1356
  console.warn(` Failed to sync ${branchDir}: ${message}`);
1357
+ // [HIGH-1] Same rationale as the `if (!completed)` push site above --
1358
+ // this catches fetch/rev-list/unexpected errors, whose message can
1359
+ // embed the bot token.
1360
+ const redactedMessage = redactCredentials(message);
1361
+ failed.push({ branch: branchDir, error: redactedMessage });
1362
+ // PR-W2: second (and only other) record site -- see the comment at
1363
+ // the `if (!completed)` block above for why these two sites are
1364
+ // disjoint.
1365
+ await this.recordRebaseFailure(branchPath, branchDir, redactedMessage);
854
1366
  }
855
1367
  }
1368
+ return { rebased, skippedDirty, failed };
856
1369
  }
857
1370
  async refreshAuthCache() {
858
1371
  if (!this.running || !this.config.refreshAuthCache)