canopycms 0.0.60 → 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 (218) 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 +51 -0
  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 +13 -3
  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 +15 -3
  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.js +1 -1
  42. package/dist/api/entries.js.map +1 -1
  43. package/dist/api/github-sync.d.ts.map +1 -1
  44. package/dist/api/github-sync.js +14 -3
  45. package/dist/api/github-sync.js.map +1 -1
  46. package/dist/api/guards.d.ts +7 -1
  47. package/dist/api/guards.d.ts.map +1 -1
  48. package/dist/api/guards.js +54 -0
  49. package/dist/api/guards.js.map +1 -1
  50. package/dist/api/index.d.ts +1 -0
  51. package/dist/api/index.d.ts.map +1 -1
  52. package/dist/api/index.js +0 -1
  53. package/dist/api/index.js.map +1 -1
  54. package/dist/api/schema.js +7 -7
  55. package/dist/api/schema.js.map +1 -1
  56. package/dist/authorization/branch.d.ts +14 -1
  57. package/dist/authorization/branch.d.ts.map +1 -1
  58. package/dist/authorization/branch.js +5 -3
  59. package/dist/authorization/branch.js.map +1 -1
  60. package/dist/authorization/index.d.ts +1 -0
  61. package/dist/authorization/index.d.ts.map +1 -1
  62. package/dist/authorization/index.js +2 -0
  63. package/dist/authorization/index.js.map +1 -1
  64. package/dist/authorization/protected-branch.d.ts +39 -0
  65. package/dist/authorization/protected-branch.d.ts.map +1 -0
  66. package/dist/authorization/protected-branch.js +43 -0
  67. package/dist/authorization/protected-branch.js.map +1 -0
  68. package/dist/branch-health.d.ts +78 -0
  69. package/dist/branch-health.d.ts.map +1 -0
  70. package/dist/branch-health.js +151 -0
  71. package/dist/branch-health.js.map +1 -0
  72. package/dist/branch-metadata.d.ts +20 -0
  73. package/dist/branch-metadata.d.ts.map +1 -1
  74. package/dist/branch-metadata.js +21 -1
  75. package/dist/branch-metadata.js.map +1 -1
  76. package/dist/branch-registry.d.ts.map +1 -1
  77. package/dist/branch-registry.js +13 -1
  78. package/dist/branch-registry.js.map +1 -1
  79. package/dist/cli/cli.js +154 -13
  80. package/dist/cli/generate-ai-content.js +54 -10
  81. package/dist/cli/init.js +72 -0
  82. package/dist/cli/sync.d.ts.map +1 -1
  83. package/dist/cli/sync.js +4 -1
  84. package/dist/cli/sync.js.map +1 -1
  85. package/dist/content-store.d.ts +16 -0
  86. package/dist/content-store.d.ts.map +1 -1
  87. package/dist/content-store.js +22 -6
  88. package/dist/content-store.js.map +1 -1
  89. package/dist/editor/BranchManager.d.ts +9 -1
  90. package/dist/editor/BranchManager.d.ts.map +1 -1
  91. package/dist/editor/BranchManager.js +20 -9
  92. package/dist/editor/BranchManager.js.map +1 -1
  93. package/dist/editor/CommentsPanel.d.ts.map +1 -1
  94. package/dist/editor/CommentsPanel.js +4 -12
  95. package/dist/editor/CommentsPanel.js.map +1 -1
  96. package/dist/editor/Editor.d.ts.map +1 -1
  97. package/dist/editor/Editor.js +46 -10
  98. package/dist/editor/Editor.js.map +1 -1
  99. package/dist/editor/EntryNavigator.d.ts +7 -0
  100. package/dist/editor/EntryNavigator.d.ts.map +1 -1
  101. package/dist/editor/EntryNavigator.js +17 -5
  102. package/dist/editor/EntryNavigator.js.map +1 -1
  103. package/dist/editor/FormRenderer.d.ts.map +1 -1
  104. package/dist/editor/FormRenderer.js +4 -0
  105. package/dist/editor/FormRenderer.js.map +1 -1
  106. package/dist/editor/admin/SystemHealthPanel.d.ts +6 -0
  107. package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -0
  108. package/dist/editor/admin/SystemHealthPanel.js +234 -0
  109. package/dist/editor/admin/SystemHealthPanel.js.map +1 -0
  110. package/dist/editor/admin/useSystemHealth.d.ts +40 -0
  111. package/dist/editor/admin/useSystemHealth.d.ts.map +1 -0
  112. package/dist/editor/admin/useSystemHealth.js +205 -0
  113. package/dist/editor/admin/useSystemHealth.js.map +1 -0
  114. package/dist/editor/comments/FieldWrapper.d.ts.map +1 -1
  115. package/dist/editor/comments/FieldWrapper.js +1 -1
  116. package/dist/editor/comments/FieldWrapper.js.map +1 -1
  117. package/dist/editor/comments/InlineCommentThread.d.ts.map +1 -1
  118. package/dist/editor/comments/InlineCommentThread.js +2 -21
  119. package/dist/editor/comments/InlineCommentThread.js.map +1 -1
  120. package/dist/editor/comments/ThreadCarousel.d.ts +2 -0
  121. package/dist/editor/comments/ThreadCarousel.d.ts.map +1 -1
  122. package/dist/editor/comments/ThreadCarousel.js +5 -2
  123. package/dist/editor/comments/ThreadCarousel.js.map +1 -1
  124. package/dist/editor/components/EditorHeader.d.ts +11 -0
  125. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  126. package/dist/editor/components/EditorHeader.js +75 -41
  127. package/dist/editor/components/EditorHeader.js.map +1 -1
  128. package/dist/editor/components/EditorSidebar.d.ts +7 -1
  129. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  130. package/dist/editor/components/EditorSidebar.js +3 -3
  131. package/dist/editor/components/EditorSidebar.js.map +1 -1
  132. package/dist/editor/fields/ImageField.d.ts.map +1 -1
  133. package/dist/editor/fields/ImageField.js +4 -1
  134. package/dist/editor/fields/ImageField.js.map +1 -1
  135. package/dist/editor/fields/StringListField.d.ts +20 -0
  136. package/dist/editor/fields/StringListField.d.ts.map +1 -0
  137. package/dist/editor/fields/StringListField.js +25 -0
  138. package/dist/editor/fields/StringListField.js.map +1 -0
  139. package/dist/editor/hooks/useBranchActions.d.ts.map +1 -1
  140. package/dist/editor/hooks/useBranchActions.js +8 -2
  141. package/dist/editor/hooks/useBranchActions.js.map +1 -1
  142. package/dist/editor/hooks/useBranchManager.d.ts +12 -3
  143. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  144. package/dist/editor/hooks/useBranchManager.js +16 -1
  145. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  146. package/dist/editor/hooks/useDraftManager.d.ts +7 -0
  147. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  148. package/dist/editor/hooks/useDraftManager.js +57 -3
  149. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  150. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  151. package/dist/editor/hooks/useEntryManager.js +25 -7
  152. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  153. package/dist/editor/media/AssetCard.d.ts.map +1 -1
  154. package/dist/editor/media/AssetCard.js +23 -2
  155. package/dist/editor/media/AssetCard.js.map +1 -1
  156. package/dist/editor/relative-time.d.ts +8 -0
  157. package/dist/editor/relative-time.d.ts.map +1 -0
  158. package/dist/editor/relative-time.js +26 -0
  159. package/dist/editor/relative-time.js.map +1 -0
  160. package/dist/git-manager.d.ts.map +1 -1
  161. package/dist/git-manager.js +10 -2
  162. package/dist/git-manager.js.map +1 -1
  163. package/dist/http/handler.d.ts.map +1 -1
  164. package/dist/http/handler.js +20 -9
  165. package/dist/http/handler.js.map +1 -1
  166. package/dist/http/router.d.ts.map +1 -1
  167. package/dist/http/router.js +2 -0
  168. package/dist/http/router.js.map +1 -1
  169. package/dist/paths/branch-name.d.ts +20 -0
  170. package/dist/paths/branch-name.d.ts.map +1 -0
  171. package/dist/paths/branch-name.js +24 -0
  172. package/dist/paths/branch-name.js.map +1 -0
  173. package/dist/paths/branch.d.ts +2 -8
  174. package/dist/paths/branch.d.ts.map +1 -1
  175. package/dist/paths/branch.js +6 -12
  176. package/dist/paths/branch.js.map +1 -1
  177. package/dist/paths/index.d.ts +2 -1
  178. package/dist/paths/index.d.ts.map +1 -1
  179. package/dist/paths/index.js +4 -1
  180. package/dist/paths/index.js.map +1 -1
  181. package/dist/services.js +9 -1
  182. package/dist/services.js.map +1 -1
  183. package/dist/task-queue/index.d.ts +2 -2
  184. package/dist/task-queue/index.d.ts.map +1 -1
  185. package/dist/task-queue/index.js +1 -1
  186. package/dist/task-queue/index.js.map +1 -1
  187. package/dist/task-queue/task-queue.d.ts +37 -1
  188. package/dist/task-queue/task-queue.d.ts.map +1 -1
  189. package/dist/task-queue/task-queue.js +104 -0
  190. package/dist/task-queue/task-queue.js.map +1 -1
  191. package/dist/task-queue/types.d.ts +11 -0
  192. package/dist/task-queue/types.d.ts.map +1 -1
  193. package/dist/types.d.ts +41 -0
  194. package/dist/types.d.ts.map +1 -1
  195. package/dist/utils/occ-json-write.d.ts.map +1 -1
  196. package/dist/utils/occ-json-write.js +7 -0
  197. package/dist/utils/occ-json-write.js.map +1 -1
  198. package/dist/utils/provisioning-lock.d.ts +19 -0
  199. package/dist/utils/provisioning-lock.d.ts.map +1 -1
  200. package/dist/utils/provisioning-lock.js +26 -0
  201. package/dist/utils/provisioning-lock.js.map +1 -1
  202. package/dist/validation/reference-validator.d.ts +17 -1
  203. package/dist/validation/reference-validator.d.ts.map +1 -1
  204. package/dist/validation/reference-validator.js +33 -10
  205. package/dist/validation/reference-validator.js.map +1 -1
  206. package/dist/worker/cms-worker.d.ts +38 -0
  207. package/dist/worker/cms-worker.d.ts.map +1 -1
  208. package/dist/worker/cms-worker.js +328 -25
  209. package/dist/worker/cms-worker.js.map +1 -1
  210. package/dist/worker/task-queue.d.ts +3 -3
  211. package/dist/worker/task-queue.d.ts.map +1 -1
  212. package/dist/worker/task-queue.js +2 -2
  213. package/dist/worker/task-queue.js.map +1 -1
  214. package/dist/worker/worker-status.d.ts +48 -0
  215. package/dist/worker/worker-status.d.ts.map +1 -0
  216. package/dist/worker/worker-status.js +53 -0
  217. package/dist/worker/worker-status.js.map +1 -0
  218. package/package.json +1 -1
@@ -8,11 +8,35 @@ import { getBranchMetadataFileManager, BranchMetadataFileManager, buildMergedBra
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.js';
12
- 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';
13
14
  const DEFAULT_TASK_TIMEOUT = 60_000;
14
15
  const DEFAULT_MAX_RETRIES = 3;
15
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
+ }
16
40
  /**
17
41
  * An error inherent to the task itself (malformed payload, unknown action):
18
42
  * retrying can never succeed, so the task should fail fast instead of
@@ -144,9 +168,25 @@ export class CmsWorker {
144
168
  this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
145
169
  this.contentRoot = config.contentRoot ?? 'content';
146
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
+ }
147
186
  async start() {
148
187
  this.running = true;
149
188
  console.log('CMS Worker starting...');
189
+ this.ensureStatusReport();
150
190
  // Acquire lock to prevent concurrent workers
151
191
  await this.acquireLock();
152
192
  // Everything below runs while holding the cross-host worker lock. A
@@ -177,6 +217,25 @@ export class CmsWorker {
177
217
  await Promise.allSettled(initialTasks);
178
218
  }
179
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
+ }
180
239
  await this.releaseLock();
181
240
  throw err;
182
241
  }
@@ -398,6 +457,11 @@ export class CmsWorker {
398
457
  catch (err) {
399
458
  const message = getErrorMessage(err);
400
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);
401
465
  // DEP-L1: only transient failures (network, 429/5xx, timeouts) are
402
466
  // worth retrying; permanent ones (malformed payload, other 4xx) would
403
467
  // just burn the retry budget on an identical doomed request.
@@ -405,12 +469,12 @@ export class CmsWorker {
405
469
  const retryCount = task.retryCount ?? 0;
406
470
  const maxRetries = task.maxRetries ?? this.maxRetries;
407
471
  if (!permanent && retryCount < maxRetries) {
408
- await retryTask(this.taskDir, task.id, message, this.log);
472
+ await retryTask(this.taskDir, task.id, persistedMessage, this.log);
409
473
  console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
410
474
  }
411
475
  else {
412
- await failTask(this.taskDir, task.id, message, this.log);
413
- await this.updateBranchMetadataOnFailure(task, message);
476
+ await failTask(this.taskDir, task.id, persistedMessage, this.log);
477
+ await this.updateBranchMetadataOnFailure(task, persistedMessage);
414
478
  console.error(permanent
415
479
  ? ' Permanently failed (non-retryable error)'
416
480
  : ` Permanently failed after ${maxRetries} retries`);
@@ -418,6 +482,15 @@ export class CmsWorker {
418
482
  }
419
483
  processed++;
420
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
+ }
421
494
  }
422
495
  /**
423
496
  * Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
@@ -493,13 +566,23 @@ export class CmsWorker {
493
566
  // direct-API path) so the list->tiebreak->update/create logic and
494
567
  // the draft->ready conversion live in one place.
495
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
+ }
496
579
  await this.pushBranchToGitHub(branch);
497
580
  const result = await createOrUpdatePullRequest({
498
581
  octokit: this.octokit,
499
582
  owner: this.config.githubOwner,
500
583
  repo: this.config.githubRepo,
501
584
  head: branch,
502
- base: optionalString(payload, 'baseBranch', this.baseBranch),
585
+ base,
503
586
  title: optionalString(payload, 'title', `Submit ${branch}`),
504
587
  body: optionalString(payload, 'body', ''),
505
588
  // Content submits (api/github-sync.ts) set this; settings-branch
@@ -660,6 +743,7 @@ export class CmsWorker {
660
743
  if (!this.running)
661
744
  return;
662
745
  console.log('Syncing git...');
746
+ const cycleStartedAt = Date.now();
663
747
  const git = simpleGit({
664
748
  baseDir: this.remoteGitPath,
665
749
  // DEP-H1: a hung fetch/push would stall the sync loop forever
@@ -667,18 +751,109 @@ export class CmsWorker {
667
751
  // is inactivity-based, so a slow-but-flowing transfer is unaffected.
668
752
  timeout: { block: this.taskTimeoutMs },
669
753
  });
670
- // Fetch all branches from GitHub using direct URL (no named remote)
671
- // We use raw git commands since simple-git's fetch() with a URL
672
- // doesn't support --prune directly
673
- await git.raw(['fetch', this.buildGitHubUrl(), '--prune', '+refs/heads/*:refs/heads/*']);
674
- console.log('Fetched from GitHub');
675
- // Push settings branches to GitHub (belt-and-suspenders for task queue).
676
- // Ensures settings reach GitHub even if a task queue entry is lost.
677
- await this.pushSettingsBranches(git);
678
- await this.refreshBaseBranchWorkspace();
679
- await this.rebaseActiveBranches();
680
- // Periodically clean up old completed/failed tasks
681
- 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;
682
857
  }
683
858
  /**
684
859
  * Fast-forward the base branch's own working-tree clone
@@ -756,10 +931,18 @@ export class CmsWorker {
756
931
  // Use rev-list instead of status.behind for the same reason as the
757
932
  // rebase loop below: status.behind only works with an upstream
758
933
  // tracking branch configured, which isn't guaranteed here.
759
- const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..origin/${this.baseBranch}`])).trim(), 10);
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);
760
943
  if (behindCount > 0) {
761
944
  try {
762
- await baseGit.merge(['--ff-only', `origin/${this.baseBranch}`]);
945
+ await baseGit.merge(['--ff-only', fetchedTip]);
763
946
  }
764
947
  catch (err) {
765
948
  console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
@@ -847,13 +1030,74 @@ export class CmsWorker {
847
1030
  clearTimeout(timer);
848
1031
  }
849
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
+ }
1087
+ }
850
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 = [];
851
1095
  let branchDirs;
852
1096
  try {
853
1097
  branchDirs = await fs.readdir(this.contentBranchesPath);
854
1098
  }
855
1099
  catch {
856
- return;
1100
+ return { rebased, skippedDirty, failed };
857
1101
  }
858
1102
  for (const branchDir of branchDirs) {
859
1103
  // Known structural entries under content-branches/, not branch
@@ -922,13 +1166,20 @@ export class CmsWorker {
922
1166
  const dirtyCheck = await branchGit.status();
923
1167
  if (dirtyCheck.files.length > 0) {
924
1168
  console.log(` Skipping ${branchDir}: has uncommitted changes`);
1169
+ skippedDirty.push(branchDir);
925
1170
  continue;
926
1171
  }
927
1172
  await branchGit.fetch('origin', this.baseBranch);
928
1173
  // Use rev-list instead of status.behind — status.behind only works when the
929
1174
  // branch has an upstream tracking branch configured, which isn't guaranteed
930
1175
  // (checkoutBranch fallback paths create branches without --track).
931
- 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);
932
1183
  const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
933
1184
  if (behindCount === 0) {
934
1185
  // Already in sync. This is the overwhelmingly common outcome per
@@ -945,8 +1196,13 @@ export class CmsWorker {
945
1196
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
946
1197
  const conflictStatus = currentMeta?.branch.conflictStatus;
947
1198
  const conflictFiles = currentMeta?.branch.conflictFiles;
948
- const alreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
1199
+ const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
949
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;
950
1206
  if (alreadyClean) {
951
1207
  continue;
952
1208
  }
@@ -955,6 +1211,7 @@ export class CmsWorker {
955
1211
  name: branchDir,
956
1212
  conflictStatus: 'clean',
957
1213
  conflictFiles: [],
1214
+ rebaseFailure: undefined,
958
1215
  },
959
1216
  });
960
1217
  continue;
@@ -965,11 +1222,16 @@ export class CmsWorker {
965
1222
  const conflictedFiles = [];
966
1223
  let nextAction = 'start';
967
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;
968
1228
  const MAX_ROUNDS = 50; // safety limit against infinite loops
969
1229
  for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
970
1230
  try {
971
1231
  if (nextAction === 'start') {
972
- 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]);
973
1235
  }
974
1236
  else if (nextAction === 'continue') {
975
1237
  await branchGit.rebase(['--continue']);
@@ -1006,6 +1268,7 @@ export class CmsWorker {
1006
1268
  // state. Previous metadata (possibly stale) is preserved until the
1007
1269
  // next successful rebase cycle corrects it.
1008
1270
  console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
1271
+ failureReason = msg || 'Unknown error';
1009
1272
  await branchGit.rebase(['--abort']).catch(() => { });
1010
1273
  break;
1011
1274
  }
@@ -1013,8 +1276,30 @@ export class CmsWorker {
1013
1276
  }
1014
1277
  }
1015
1278
  if (!completed) {
1016
- 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`);
1017
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);
1018
1303
  continue;
1019
1304
  }
1020
1305
  // The rebase rewrote the branch clone's working tree — mark ContentStore
@@ -1055,14 +1340,32 @@ export class CmsWorker {
1055
1340
  name: branchDir,
1056
1341
  conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
1057
1342
  conflictFiles: conflictIdsDeduped,
1343
+ // PR-W2: the cycle completed successfully -- clear any prior
1344
+ // failure record regardless of conflict outcome.
1345
+ rebaseFailure: undefined,
1058
1346
  },
1059
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);
1060
1353
  }
1061
1354
  catch (err) {
1062
1355
  const message = err instanceof Error ? err.message : 'Unknown error';
1063
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);
1064
1366
  }
1065
1367
  }
1368
+ return { rebased, skippedDirty, failed };
1066
1369
  }
1067
1370
  async refreshAuthCache() {
1068
1371
  if (!this.running || !this.config.refreshAuthCache)