canopycms 0.0.67 → 0.0.68-int.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +21 -14
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +111 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +10 -1
  23. package/dist/api/github-sync.js +20 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -2,39 +2,31 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { simpleGit } from 'simple-git';
4
4
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
5
+ import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
6
+ import { ORPHAN_YOUTH_THRESHOLD_MS } from '../branch-health.js';
7
+ import { classifyFinalDir, parseDirStamp, quarantineResidueAt, sweepProvisioningLeftovers, } from '../branch-provisioning.js';
5
8
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
- import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv } from '../git-manager.js';
9
+ import { GITHUB_TRACKING_REF_PREFIX, ensureGitExcludePattern, gitNetworkChildEnv, } from '../git-manager.js';
7
10
  import { RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
11
+ import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
12
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
8
13
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
9
- import { isNonFastForwardRejection } from '../utils/git.js';
14
+ import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
15
+ import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
10
16
  import { hasPendingHistoryRewrite } from './history-rewrite.js';
11
17
  import { runRebaseCycle } from './rebase.js';
12
18
  import { cleanupOldTasks } from '../task-queue/cms-task-queue.js';
13
19
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
14
20
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
21
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
22
+ import { maintainRemoteGit } from './remote-git-maintenance.js';
23
+ import { reapplySparseCones } from './sparse-cone.js';
15
24
  /**
16
25
  * [C1] Retention window for `.trash-*` branch directories left behind by the
17
26
  * admin purge action (api/admin-branch-health.ts). Matches
18
27
  * cleanupOldTasks's default task retention for consistency.
19
28
  */
20
29
  const TRASH_RETENTION_MS = 30 * 24 * 60 * 60_000;
21
- /** Matches `.trash-{dirName}-{STAMP}` names, capturing the trailing stamp. */
22
- const TRASH_DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
23
- /**
24
- * Parse a purge-generated `YYYYMMDDTHHMMSSZ` stamp into a Date, or null if
25
- * malformed. Age comes ONLY from this name-embedded stamp, never the dir's
26
- * own mtime -- `fs.rename` preserves the original directory's mtime, so an
27
- * mtime-based retention check would delete a months-stale orphan's trash on
28
- * the very first cleanup pass after purge.
29
- */
30
- function parseTrashStamp(stamp) {
31
- const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
32
- if (!match)
33
- return null;
34
- const [, year, month, day, hour, minute, second] = match;
35
- const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
36
- return Number.isNaN(date.getTime()) ? null : date;
37
- }
38
30
  /**
39
31
  * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
40
32
  * remote.git to GitHub. Non-fatal: a no-op push for an up-to-date branch just
@@ -288,6 +280,27 @@ export async function syncGit(ctx) {
288
280
  // mask the real error on a failed one. Failures rethrow, so scheduleLoop's
289
281
  // per-cycle catch stays the loud path.
290
282
  try {
283
+ // Best-effort and ahead of the GitHub fetch, so a GitHub outage never
284
+ // stalls upkeep and a failure here never costs the cycle. start() runs a
285
+ // cycle at once, so residue repair also runs on boot.
286
+ try {
287
+ await repairBranchDirResidue(ctx);
288
+ }
289
+ catch (err) {
290
+ workerLogWarn(`Branch directory residue repair failed: ${getErrorMessage(err)}`);
291
+ }
292
+ try {
293
+ await reapplySparseCones(ctx);
294
+ }
295
+ catch (err) {
296
+ workerLogWarn(`Sparse-checkout cone update failed: ${getErrorMessage(err)}`);
297
+ }
298
+ try {
299
+ await maintainRemoteGit(ctx.remoteGitPath);
300
+ }
301
+ catch (err) {
302
+ workerLogWarn(`remote.git maintenance failed: ${getErrorMessage(err)}`);
303
+ }
291
304
  // Direct URL (no named remote), into the GITHUB_TRACKING_REF_PREFIX
292
305
  // remote-tracking namespace rather than refs/heads/* -- see that constant's
293
306
  // doc comment for the destructive-fetch bug this avoids. Raw git, because
@@ -306,7 +319,7 @@ export async function syncGit(ctx) {
306
319
  // correctness dependency now that the fetch can't clobber refs/heads/*
307
320
  // -- this could run before or after them just as safely.
308
321
  await pushSettingsBranches(ctx, git, trackedNames);
309
- await refreshBaseBranchWorkspace(ctx);
322
+ const baseRefresh = await refreshBaseBranchWorkspace(ctx);
310
323
  const rebaseSummary = await runRebaseCycle(ctx);
311
324
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
312
325
  // [C1] Sweep branch directories the admin purge action trashed more than
@@ -323,9 +336,14 @@ export async function syncGit(ctx) {
323
336
  report.lastGitSync = {
324
337
  durationMs: Date.now() - cycleStartedAt,
325
338
  rebased: rebaseSummary.rebased,
326
- skippedDirty: rebaseSummary.skippedDirty,
327
- skippedLocked: rebaseSummary.skippedLocked,
339
+ skippedDirty: baseRefresh.outcome === 'skipped-dirty'
340
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
341
+ : rebaseSummary.skippedDirty,
342
+ skippedLocked: baseRefresh.outcome === 'skipped-locked'
343
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedLocked]
344
+ : rebaseSummary.skippedLocked,
328
345
  failed: rebaseSummary.failed,
346
+ baseRefresh,
329
347
  tracked: trackedSummary,
330
348
  };
331
349
  await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
@@ -343,13 +361,87 @@ export async function syncGit(ctx) {
343
361
  throw err;
344
362
  }
345
363
  }
364
+ /** Foreign repositories already reported by this process, so each is logged once. */
365
+ const reportedForeignDirs = new Set();
366
+ /**
367
+ * Repair what killed provisioning and deletes left under the branches root: quarantine residue
368
+ * at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
369
+ * {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
370
+ * siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
371
+ * lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
372
+ * revisited next cycle.
373
+ * @internal Exported for tests; `syncGit` runs it.
374
+ */
375
+ export async function repairBranchDirResidue(ctx, now = Date.now()) {
376
+ let entries;
377
+ try {
378
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
379
+ }
380
+ catch (err) {
381
+ if (isNodeError(err) && err.code === 'ENOENT')
382
+ return;
383
+ throw err;
384
+ }
385
+ for (const entry of entries) {
386
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
387
+ continue;
388
+ try {
389
+ await repairOneBranchDir(ctx, entry.name, now);
390
+ }
391
+ catch (err) {
392
+ workerLogWarn(` Residue check of ${entry.name} failed: ${getErrorMessage(err)}`);
393
+ }
394
+ }
395
+ const leftovers = await sweepProvisioningLeftovers(ctx.contentBranchesPath, path.dirname(ctx.contentBranchesPath), now);
396
+ for (const { name, action, detail } of leftovers) {
397
+ const line = ` Leftover ${name}: ${action}${detail ? ` (${detail})` : ''}`;
398
+ if (action === 'removed')
399
+ workerLog(line);
400
+ else if (action === 'failed')
401
+ workerLogWarn(line);
402
+ else
403
+ workerLogError(line);
404
+ }
405
+ }
406
+ async function repairOneBranchDir(ctx, dirName, now) {
407
+ const dirPath = path.join(ctx.contentBranchesPath, dirName);
408
+ // One stat for the common case: a live (or corrupt) branch is never residue.
409
+ const hasMeta = await fs.access(path.join(dirPath, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
410
+ if (hasMeta)
411
+ return;
412
+ const state = await classifyFinalDir(dirPath, ctx.remoteGitPath);
413
+ if (state.kind === 'foreign' && !reportedForeignDirs.has(dirPath)) {
414
+ reportedForeignDirs.add(dirPath);
415
+ workerLogWarn(` ${dirName} holds a git repository this deployment did not create; left for an admin`);
416
+ }
417
+ if (state.kind !== 'residue')
418
+ return;
419
+ let release;
420
+ try {
421
+ release = await tryAcquireProvisioningLock(ctx.contentBranchesPath, branchProvisioningLockName(dirName));
422
+ }
423
+ catch (err) {
424
+ if (isNodeError(err) && err.code === 'ELOCKED')
425
+ return;
426
+ throw err;
427
+ }
428
+ try {
429
+ await quarantineResidueAt(ctx.contentBranchesPath, dirName, {
430
+ minQuietMs: ORPHAN_YOUTH_THRESHOLD_MS,
431
+ expectedRemoteUrl: ctx.remoteGitPath,
432
+ now,
433
+ });
434
+ }
435
+ finally {
436
+ await releaseProvisionedWorkspace(release, dirName);
437
+ }
438
+ }
346
439
  /**
347
440
  * [C1] Remove `.trash-*` branch directories (created by the admin purge action,
348
- * api/admin-branch-health.ts) whose name-embedded stamp is older than
349
- * {@link TRASH_RETENTION_MS}. A name that doesn't match `.trash-{dirName}-
350
- * {STAMP}`, or whose stamp fails to parse, is left alone and logged once per
351
- * cycle: purge is the only writer of this naming scheme, so an unparseable name
352
- * is unexpected and worth a human looking rather than a silent skip.
441
+ * api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
442
+ * whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
443
+ * whose trailing stamp fails to parse is left alone and logged once per cycle:
444
+ * both writers end every name with one, so it is worth a human looking.
353
445
  */
354
446
  export async function cleanupTrashedBranchDirs(ctx) {
355
447
  let entries;
@@ -367,8 +459,7 @@ export async function cleanupTrashedBranchDirs(ctx) {
367
459
  for (const name of entries) {
368
460
  if (!name.startsWith('.trash-'))
369
461
  continue;
370
- const stampMatch = TRASH_DIR_STAMP_RE.exec(name);
371
- const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
462
+ const stampDate = parseDirStamp(name);
372
463
  if (!stampDate) {
373
464
  if (!loggedUnparseable) {
374
465
  workerLog(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
@@ -393,37 +484,76 @@ export async function cleanupTrashedBranchDirs(ctx) {
393
484
  }
394
485
  return removed;
395
486
  }
487
+ /**
488
+ * Warned (base path + tracked file list) pairs, so a repo that tracks
489
+ * `.canopy-meta/` is called out once per worker process rather than every cycle;
490
+ * worker-status.json carries it every cycle regardless.
491
+ */
492
+ const warnedTrackedCanopyState = new Set();
396
493
  /**
397
494
  * Fast-forward the base branch's own working-tree clone
398
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
495
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
399
496
  * cycle, so the drift window is bounded by gitSyncInterval.
400
497
  *
401
498
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
402
499
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
403
500
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
404
- * new branch "from base" silently gets a stale snapshot.
501
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
502
+ * goes to worker-status.json for the same reason.
405
503
  *
406
- * ff-only on purpose: this clone must stay a linear mirror of
407
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
504
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
505
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
408
506
  * history) is left untouched rather than force-resolved.
507
+ *
508
+ * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
509
+ * content-write lock, both try-only: the base branch is writable in dev, and
510
+ * a save racing the merge's working-tree update can be overwritten by it.
409
511
  */
410
512
  export async function refreshBaseBranchWorkspace(ctx) {
411
513
  // Sanitized name for the workspace directory (a base branch containing
412
514
  // e.g. '/' would otherwise stat a wrong nested path here forever).
413
515
  const basePath = path.join(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
414
- const gitDir = path.join(basePath, '.git');
516
+ let trackedCanopyMeta;
517
+ let releaseProvisioning;
518
+ let releaseContentLock;
415
519
  try {
416
- let gitDirStat;
417
- try {
418
- gitDirStat = await fs.stat(gitDir);
419
- }
420
- catch {
421
- gitDirStat = null;
520
+ // Held to the end, so no git step below races a clone of this directory.
521
+ const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
522
+ if (hold.kind === 'locked') {
523
+ workerLog(`Base branch workspace (${ctx.baseBranch}): provisioning lock held elsewhere, skipping refresh`);
524
+ return { outcome: 'skipped-locked' };
422
525
  }
423
- if (!gitDirStat || !gitDirStat.isDirectory()) {
526
+ if (hold.kind === 'not-provisioned') {
424
527
  workerLog(`Base branch workspace (${ctx.baseBranch}): not yet provisioned, skipping refresh`);
425
- return;
528
+ return { outcome: 'skipped-not-provisioned' };
426
529
  }
530
+ releaseProvisioning = hold.release;
531
+ let contentLockCompromised = false;
532
+ try {
533
+ releaseContentLock = await tryAcquireContentWriteLock(basePath, (lockErr) => {
534
+ contentLockCompromised = true;
535
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): content-write lock compromised mid-refresh: ${getErrorMessage(lockErr)}`);
536
+ });
537
+ }
538
+ catch (lockErr) {
539
+ if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
540
+ workerLog(`Base branch workspace (${ctx.baseBranch}): content write in progress, skipping refresh`);
541
+ return { outcome: 'skipped-locked' };
542
+ }
543
+ throw lockErr;
544
+ }
545
+ // Checked before each destructive step: a lost provisioning lock means
546
+ // another process may be cloning into this directory, a lost content lock
547
+ // that an editor save may be landing in it.
548
+ const lockLost = () => {
549
+ if (!hold.isCompromised() && !contentLockCompromised)
550
+ return null;
551
+ const lost = hold.isCompromised() ? 'provisioning' : 'content-write';
552
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): ${lost} lock lost mid-refresh, stopping`);
553
+ return { outcome: 'skipped-locked', trackedCanopyMeta };
554
+ };
555
+ // Idempotent, and applied every cycle so any clone lacking it gets it.
556
+ await ensureGitExcludePattern(basePath, `${CANOPY_META_DIR}/`);
427
557
  const baseGit = simpleGit({
428
558
  baseDir: basePath,
429
559
  // Keep git non-interactive during the merge so it never blocks on an
@@ -437,37 +567,80 @@ export async function refreshBaseBranchWorkspace(ctx) {
437
567
  // Inactivity-based, so a slow-but-flowing transfer is unaffected.
438
568
  timeout: { block: ctx.taskTimeoutMs },
439
569
  });
570
+ const trackedState = await listTrackedCanopyState(baseGit);
571
+ if (trackedState.length > 0) {
572
+ trackedCanopyMeta = trackedState.slice(0, MAX_REPORTED_PATHS);
573
+ const warnKey = `${basePath}\0${trackedState.join('\0')}`;
574
+ if (!warnedTrackedCanopyState.has(warnKey)) {
575
+ warnedTrackedCanopyState.add(warnKey);
576
+ workerLogWarn(`Base branch (${ctx.baseBranch}) tracks canopycms state that must not be committed: ` +
577
+ `${trackedState.join(', ')}. To fix, ${TRACKED_CANOPY_STATE_FIX}.`);
578
+ }
579
+ }
580
+ let status = await baseGit.status();
581
+ const lostBeforeRestore = lockLost();
582
+ if (lostBeforeRestore)
583
+ return lostBeforeRestore;
584
+ if (await restoreRetiredSchemaCache(baseGit, status)) {
585
+ workerLog(`Base branch workspace (${ctx.baseBranch}): restored the retired in-tree schema cache`);
586
+ status = await baseGit.status();
587
+ }
440
588
  // Nothing makes this clone read-only, and a direct edit here wedges every
441
589
  // editor's view of the base branch until an operator intervenes, so a dirty
442
- // tree is loud, not a quiet skip. Only TRACKED changes block the refresh: a
443
- // stray untracked file must not wedge the fast-forward forever, and if one
444
- // would collide with incoming content the --ff-only merge below refuses on
445
- // its own and that failure is already logged loudly.
446
- const status = await baseGit.status();
447
- const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
590
+ // tree is loud, not a quiet skip. Only TRACKED content changes block the
591
+ // refresh: canopycms's own state never does, and neither does a stray
592
+ // untracked file. A modified tracked file or an untracked one that would
593
+ // collide with incoming content makes the --ff-only merge below refuse and
594
+ // report `failed`; an IGNORED file, which every .canopy-meta file is, git
595
+ // overwrites instead.
596
+ const trackedDirty = status.files
597
+ .filter((f) => !isUntracked(f) && !isCanopyInternalPath(f.path))
598
+ .map((f) => f.path);
448
599
  if (trackedDirty.length > 0) {
449
- workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
450
- return;
600
+ workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.join(', ')}`);
601
+ return {
602
+ outcome: 'skipped-dirty',
603
+ dirtyFiles: trackedDirty.slice(0, MAX_REPORTED_PATHS),
604
+ message: `${trackedDirty.length} uncommitted tracked file(s) in the base branch workspace`,
605
+ trackedCanopyMeta,
606
+ };
451
607
  }
452
608
  // Raw (unsanitized) name from here on: these are git ref operations
453
- // against origin/<baseBranch>, not filesystem paths, so they must use
609
+ // against remote.git's <baseBranch>, not filesystem paths, so they must use
454
610
  // the same name GitHub knows the branch by.
455
- await baseGit.fetch('origin', ctx.baseBranch);
611
+ await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
456
612
  // rev-list, not status.behind, which needs an upstream tracking branch that
457
- // is not guaranteed here. Against the just-fetched tip rather than
458
- // origin/<base>: workspaces are cloned --single-branch (git-manager.ts), so
459
- // for any other base branch origin/<base> never exists and rev-list dies
460
- // with "ambiguous argument". Pin FETCH_HEAD to a SHA immediately -- it is
461
- // one shared mutable file per repo, silently repointed by any other fetch.
613
+ // is not guaranteed here. Against the just-fetched tip: a fetch by path
614
+ // updates no remote-tracking ref. Pin FETCH_HEAD to a SHA immediately -- it
615
+ // is one shared mutable file per repo, silently repointed by any other fetch.
462
616
  const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
463
617
  const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
464
618
  if (behindCount > 0) {
619
+ const lostBeforeMerge = lockLost();
620
+ if (lostBeforeMerge)
621
+ return lostBeforeMerge;
622
+ // Untrack, in the index only, any state the tip has stopped tracking:
623
+ // the merge would otherwise refuse to overwrite a modified copy, or
624
+ // delete a clean one from disk. Safe here and not in the rebase loop,
625
+ // because this clone has no commits of its own to replay. State still
626
+ // tracked upstream is left for the merge, which refuses where upstream
627
+ // changed a locally modified copy.
628
+ const { droppedUpstream } = await splitByUpstreamTracking(baseGit, trackedState, fetchedTip);
629
+ if (droppedUpstream.length > 0) {
630
+ await untrackInIndex(baseGit, droppedUpstream);
631
+ workerLog(`Base branch workspace (${ctx.baseBranch}): stopped tracking ${droppedUpstream.join(', ')}, as upstream has`);
632
+ }
465
633
  try {
466
634
  await baseGit.merge(['--ff-only', fetchedTip]);
467
635
  }
468
636
  catch (err) {
469
637
  workerLogError(`Base branch workspace (${ctx.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
470
- return;
638
+ return {
639
+ outcome: 'failed',
640
+ // [REDACT] Served to the browser by the admin panel.
641
+ message: redactCredentials(`failed to fast-forward: ${getErrorMessage(err)}`),
642
+ trackedCanopyMeta,
643
+ };
471
644
  }
472
645
  await invalidateBranchContentCaches(basePath);
473
646
  }
@@ -491,8 +664,30 @@ export async function refreshBaseBranchWorkspace(ctx) {
491
664
  workerLog(behindCount > 0
492
665
  ? `Base branch workspace (${ctx.baseBranch}): fast-forwarded ${behindCount} commit(s)`
493
666
  : `Base branch workspace (${ctx.baseBranch}): up to date`);
667
+ if (trackedCanopyMeta && behindCount > 0) {
668
+ const stillTracked = await listTrackedCanopyState(baseGit);
669
+ trackedCanopyMeta =
670
+ stillTracked.length > 0 ? stillTracked.slice(0, MAX_REPORTED_PATHS) : undefined;
671
+ }
672
+ return { outcome: behindCount > 0 ? 'refreshed' : 'up-to-date', trackedCanopyMeta };
494
673
  }
495
674
  catch (err) {
496
675
  workerLogError(`Base branch workspace (${ctx.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
676
+ // [REDACT] Served to the browser by the admin panel.
677
+ return {
678
+ outcome: 'failed',
679
+ message: redactCredentials(getErrorMessage(err)),
680
+ trackedCanopyMeta,
681
+ };
682
+ }
683
+ finally {
684
+ if (releaseContentLock) {
685
+ await releaseContentLock().catch((err) => {
686
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): failed to release content-write lock: ${getErrorMessage(err)}`);
687
+ });
688
+ }
689
+ if (releaseProvisioning) {
690
+ await releaseProvisionedWorkspace(releaseProvisioning, ctx.sanitizedBaseBranch);
691
+ }
497
692
  }
498
693
  }
@@ -72,7 +72,7 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
72
72
  * Returns whether the push landed. A refused lease means a concurrent Lambda
73
73
  * push moved the ref; logged and retried by the self-heal pass, never thrown.
74
74
  */
75
- export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
75
+ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
76
76
  /**
77
77
  * Queue the GitHub hop for a branch whose rewritten history now sits in
78
78
  * `remote.git`, so an open PR's head follows the rebase within a cycle instead
@@ -108,7 +108,7 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
108
108
  // Real flags must precede --end-of-options; everything after it is
109
109
  // positional (see GitManager.push() for the same guard).
110
110
  '--end-of-options',
111
- 'origin',
111
+ ctx.remoteGitPath,
112
112
  `${branchRef}:${branchRef}`,
113
113
  ]);
114
114
  workerLog(` Published rebased ${branchRef} into remote.git`);
@@ -0,0 +1,35 @@
1
+ export type ProvisionedWorkspaceHold = {
2
+ kind: 'held';
3
+ release: () => Promise<void>;
4
+ /**
5
+ * Whether the lock was lost mid-hold (its marker vanished or was taken over), so another
6
+ * process may now be cloning into this directory. Callers check it before each destructive
7
+ * git step and stop rather than carry on unguarded.
8
+ */
9
+ isCompromised: () => boolean;
10
+ }
11
+ /** Another process holds the provisioning lock: a clone, or an admin purge or repair, is in flight. */
12
+ | {
13
+ kind: 'locked';
14
+ }
15
+ /** No clone with metadata at this path yet. */
16
+ | {
17
+ kind: 'not-provisioned';
18
+ };
19
+ /**
20
+ * Take a branch workspace's provisioning lock for the sync loop, the same cross-host lock the
21
+ * Lambda holds while it publishes the workspace (branch-provisioning.ts) and admin actions hold
22
+ * while they repair or purge it. The caller holds it across its dirty check and every git step, and
23
+ * releases it in a `finally`.
24
+ *
25
+ * Zero retries: the worker skips and retries next cycle rather than wait, so it never blocks on
26
+ * the Lambda and cannot deadlock with it. The rebase nests the content-write lock inside this one;
27
+ * the worker takes that one try-only as well, so the nesting cannot deadlock either.
28
+ *
29
+ * Provisioned means a `.git` directory AND `branch.json`. A workspace is published complete with
30
+ * both ([PROV-1], branch-provisioning.ts), so anything else at a branch name is a leftover for
31
+ * `repairBranchDirResidue` (git-sync.ts).
32
+ */
33
+ export declare function holdProvisionedWorkspace(contentBranchesPath: string, dirName: string): Promise<ProvisionedWorkspaceHold>;
34
+ /** Release a hold, logging rather than throwing: the sync loop's callers never throw. */
35
+ export declare function releaseProvisionedWorkspace(release: () => Promise<void>, dirName: string): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
4
+ import { getErrorMessage, isNodeError } from '../utils/error.js';
5
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
6
+ import { workerLogWarn } from './log.js';
7
+ /**
8
+ * Take a branch workspace's provisioning lock for the sync loop, the same cross-host lock the
9
+ * Lambda holds while it publishes the workspace (branch-provisioning.ts) and admin actions hold
10
+ * while they repair or purge it. The caller holds it across its dirty check and every git step, and
11
+ * releases it in a `finally`.
12
+ *
13
+ * Zero retries: the worker skips and retries next cycle rather than wait, so it never blocks on
14
+ * the Lambda and cannot deadlock with it. The rebase nests the content-write lock inside this one;
15
+ * the worker takes that one try-only as well, so the nesting cannot deadlock either.
16
+ *
17
+ * Provisioned means a `.git` directory AND `branch.json`. A workspace is published complete with
18
+ * both ([PROV-1], branch-provisioning.ts), so anything else at a branch name is a leftover for
19
+ * `repairBranchDirResidue` (git-sync.ts).
20
+ */
21
+ export async function holdProvisionedWorkspace(contentBranchesPath, dirName) {
22
+ let release;
23
+ let compromised = false;
24
+ try {
25
+ release = await tryAcquireProvisioningLock(contentBranchesPath, branchProvisioningLockName(dirName), (err) => {
26
+ compromised = true;
27
+ workerLogWarn(` Provisioning lock for ${dirName} was compromised mid-hold: ${getErrorMessage(err)}`);
28
+ });
29
+ }
30
+ catch (err) {
31
+ if (isNodeError(err) && err.code === 'ELOCKED')
32
+ return { kind: 'locked' };
33
+ throw err;
34
+ }
35
+ const root = path.join(contentBranchesPath, dirName);
36
+ const [gitIsDir, hasMetadata] = await Promise.all([
37
+ fs.stat(path.join(root, '.git')).then((stat) => stat.isDirectory(), () => false),
38
+ fs.stat(path.join(root, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false),
39
+ ]);
40
+ if (gitIsDir && hasMetadata)
41
+ return { kind: 'held', release, isCompromised: () => compromised };
42
+ await releaseProvisionedWorkspace(release, dirName);
43
+ return { kind: 'not-provisioned' };
44
+ }
45
+ /** Release a hold, logging rather than throwing: the sync loop's callers never throw. */
46
+ export async function releaseProvisionedWorkspace(release, dirName) {
47
+ await release().catch((err) => {
48
+ workerLogWarn(` Failed to release the provisioning lock for ${dirName}: ${getErrorMessage(err)}`);
49
+ });
50
+ }
@@ -20,7 +20,7 @@ import type { WorkerContext } from './worker-context.js';
20
20
  * but still need their PR's resolution polled, and this loop is the only thing
21
21
  * that walks every branch workspace.
22
22
  */
23
- export type RebaseContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'octokit' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting'>;
23
+ export type RebaseContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'octokit' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch'>;
24
24
  /**
25
25
  * Per-cycle outcome of `rebaseActiveBranches()`, folded by `syncGit()` into the
26
26
  * worker's self-reported status (`WorkerStatusReport.lastGitSync`, see