canopycms 0.0.67 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ import { SCHEMA_CACHE_FILE } from '../branch-schema-cache.js';
2
+ import { CANOPY_META_DIR, isCanopyInternalPath } from '../utils/git.js';
3
+ /**
4
+ * How the sync loop treats canopycms's own state (`.canopy-meta/`) in a branch clone. It is
5
+ * never content, so untracked state never makes a clone "dirty" for sync, but git refuses some
6
+ * operations over it while the adopter's repo tracks it. Only the adopter can stop that; once
7
+ * they have, the base clone follows on its next fast-forward ({@link untrackInIndex}), and a
8
+ * branch clone with modified tracked state needs an operator.
9
+ */
10
+ /** The fix an operator applies when an adopter repo tracks `.canopy-meta/`. */
11
+ export const TRACKED_CANOPY_STATE_FIX = 'run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit';
12
+ /** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
13
+ export const MAX_REPORTED_PATHS = 10;
14
+ /** The schema cache's location in a clone before it moved under `.git/` (branch-schema-cache.ts). */
15
+ const RETIRED_SCHEMA_CACHE_PATH = `${CANOPY_META_DIR}/${SCHEMA_CACHE_FILE}`;
16
+ export function isUntracked(file) {
17
+ return file.index === '?' && file.working_dir === '?';
18
+ }
19
+ /** NUL-separated git output, which is never quoted, unlike `core.quotePath` line output. */
20
+ function splitNul(out) {
21
+ return out.split('\0').filter((entry) => entry.length > 0);
22
+ }
23
+ /** Files under `.canopy-meta/` that the clone's index tracks. */
24
+ export async function listTrackedCanopyState(git) {
25
+ return splitNul(await git.raw(['ls-files', '-z', '--', CANOPY_META_DIR]));
26
+ }
27
+ /**
28
+ * Discard local changes to the schema cache's retired in-tree copy, so a clone of a repo that
29
+ * committed it can sync again. Nothing reads or writes that path in a clone any more, so its
30
+ * content is dead; but while it differs from HEAD, `git rebase` refuses to start and a
31
+ * fast-forward that touches it (including the adopter's own commit untracking it) refuses too.
32
+ * An untracked copy blocks neither and a newly staged one has nothing in HEAD to restore, so
33
+ * both are left alone. Returns whether it restored anything; the caller re-reads status if so.
34
+ */
35
+ export async function restoreRetiredSchemaCache(git, status) {
36
+ const entry = status.files.find((f) => f.path === RETIRED_SCHEMA_CACHE_PATH);
37
+ if (!entry || entry.index === '?' || entry.index === 'A')
38
+ return false;
39
+ await git.raw(['checkout', 'HEAD', '--', RETIRED_SCHEMA_CACHE_PATH]);
40
+ return true;
41
+ }
42
+ /**
43
+ * The changed tracked `.canopy-meta/` files in `status`: what git refuses to rebase over, and
44
+ * what a fast-forward refuses to overwrite where it touches them.
45
+ */
46
+ export function trackedCanopyStateChanges(status) {
47
+ return status.files
48
+ .filter((f) => isCanopyInternalPath(f.path) && !isUntracked(f))
49
+ .map((f) => f.path);
50
+ }
51
+ /**
52
+ * Split `paths` by whether the commit `tip` still tracks them. Those it no longer tracks, the
53
+ * adopter has untracked upstream, so a fast-forward onto `tip` would delete them from disk unless
54
+ * {@link untrackInIndex} takes them out of the index first.
55
+ */
56
+ export async function splitByUpstreamTracking(git, paths, tip) {
57
+ if (paths.length === 0)
58
+ return { droppedUpstream: [], stillTracked: [] };
59
+ const tracked = new Set(splitNul(await git.raw(['ls-tree', '-r', '-z', '--name-only', tip, '--', CANOPY_META_DIR])));
60
+ return {
61
+ droppedUpstream: paths.filter((p) => !tracked.has(p)),
62
+ stillTracked: paths.filter((p) => tracked.has(p)),
63
+ };
64
+ }
65
+ /**
66
+ * Remove `paths` from this clone's index, leaving them on disk as untracked, excluded state.
67
+ * Index-only, so a concurrent write to them (branch metadata, comments) is not lost. Only for a
68
+ * clone with no commits of its own: a rebase that replays a commit touching these paths writes
69
+ * that commit's bytes over them. Paths already out of the index are ignored.
70
+ */
71
+ export async function untrackInIndex(git, paths) {
72
+ if (paths.length === 0)
73
+ return;
74
+ await git.raw(['rm', '--cached', '--ignore-unmatch', '-q', '--', ...paths]);
75
+ }
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from 'simple-git';
2
+ import type { BaseRefreshReport } from '../types.js';
2
3
  import { type RebaseContext } from './rebase.js';
3
4
  import type { WorkerContext } from './worker-context.js';
4
5
  /**
@@ -56,10 +57,11 @@ export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'cont
56
57
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
57
58
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
58
59
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
59
- * new branch "from base" silently gets a stale snapshot.
60
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
61
+ * goes to worker-status.json for the same reason.
60
62
  *
61
63
  * ff-only on purpose: this clone must stay a linear mirror of
62
64
  * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
63
65
  * history) is left untouched rather than force-resolved.
64
66
  */
65
- export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<void>;
67
+ export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<BaseRefreshReport>;
@@ -3,15 +3,17 @@ import path from 'node:path';
3
3
  import { simpleGit } from 'simple-git';
4
4
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
5
5
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
- import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv } from '../git-manager.js';
6
+ import { GITHUB_TRACKING_REF_PREFIX, ensureGitExcludePattern, gitNetworkChildEnv, } from '../git-manager.js';
7
7
  import { RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
8
8
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
9
- import { isNonFastForwardRejection } from '../utils/git.js';
9
+ import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
10
+ import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
10
11
  import { hasPendingHistoryRewrite } from './history-rewrite.js';
11
12
  import { runRebaseCycle } from './rebase.js';
12
13
  import { cleanupOldTasks } from '../task-queue/cms-task-queue.js';
13
14
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
14
15
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
16
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
15
17
  /**
16
18
  * [C1] Retention window for `.trash-*` branch directories left behind by the
17
19
  * admin purge action (api/admin-branch-health.ts). Matches
@@ -306,7 +308,7 @@ export async function syncGit(ctx) {
306
308
  // correctness dependency now that the fetch can't clobber refs/heads/*
307
309
  // -- this could run before or after them just as safely.
308
310
  await pushSettingsBranches(ctx, git, trackedNames);
309
- await refreshBaseBranchWorkspace(ctx);
311
+ const baseRefresh = await refreshBaseBranchWorkspace(ctx);
310
312
  const rebaseSummary = await runRebaseCycle(ctx);
311
313
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
312
314
  // [C1] Sweep branch directories the admin purge action trashed more than
@@ -323,9 +325,14 @@ export async function syncGit(ctx) {
323
325
  report.lastGitSync = {
324
326
  durationMs: Date.now() - cycleStartedAt,
325
327
  rebased: rebaseSummary.rebased,
326
- skippedDirty: rebaseSummary.skippedDirty,
327
- skippedLocked: rebaseSummary.skippedLocked,
328
+ skippedDirty: baseRefresh.outcome === 'skipped-dirty'
329
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
330
+ : rebaseSummary.skippedDirty,
331
+ skippedLocked: baseRefresh.outcome === 'skipped-locked'
332
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedLocked]
333
+ : rebaseSummary.skippedLocked,
328
334
  failed: rebaseSummary.failed,
335
+ baseRefresh,
329
336
  tracked: trackedSummary,
330
337
  };
331
338
  await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
@@ -393,6 +400,12 @@ export async function cleanupTrashedBranchDirs(ctx) {
393
400
  }
394
401
  return removed;
395
402
  }
403
+ /**
404
+ * Warned (base path + tracked file list) pairs, so a repo that tracks
405
+ * `.canopy-meta/` is called out once per worker process rather than every cycle;
406
+ * worker-status.json carries it every cycle regardless.
407
+ */
408
+ const warnedTrackedCanopyState = new Set();
396
409
  /**
397
410
  * Fast-forward the base branch's own working-tree clone
398
411
  * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
@@ -401,7 +414,8 @@ export async function cleanupTrashedBranchDirs(ctx) {
401
414
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
402
415
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
403
416
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
404
- * new branch "from base" silently gets a stale snapshot.
417
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
418
+ * goes to worker-status.json for the same reason.
405
419
  *
406
420
  * ff-only on purpose: this clone must stay a linear mirror of
407
421
  * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
@@ -411,19 +425,30 @@ export async function refreshBaseBranchWorkspace(ctx) {
411
425
  // Sanitized name for the workspace directory (a base branch containing
412
426
  // e.g. '/' would otherwise stat a wrong nested path here forever).
413
427
  const basePath = path.join(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
414
- const gitDir = path.join(basePath, '.git');
428
+ let trackedCanopyMeta;
429
+ let releaseProvisioning;
415
430
  try {
416
- let gitDirStat;
417
- try {
418
- gitDirStat = await fs.stat(gitDir);
419
- }
420
- catch {
421
- gitDirStat = null;
431
+ // Held to the end, so no git step below races a clone of this directory.
432
+ const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
433
+ if (hold.kind === 'locked') {
434
+ workerLog(`Base branch workspace (${ctx.baseBranch}): provisioning lock held elsewhere, skipping refresh`);
435
+ return { outcome: 'skipped-locked' };
422
436
  }
423
- if (!gitDirStat || !gitDirStat.isDirectory()) {
437
+ if (hold.kind === 'not-provisioned') {
424
438
  workerLog(`Base branch workspace (${ctx.baseBranch}): not yet provisioned, skipping refresh`);
425
- return;
439
+ return { outcome: 'skipped-not-provisioned' };
426
440
  }
441
+ releaseProvisioning = hold.release;
442
+ // Checked before each destructive step: a lost lock means another process
443
+ // may be cloning into this directory.
444
+ const lockLost = () => {
445
+ if (!hold.isCompromised())
446
+ return null;
447
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): provisioning lock lost mid-refresh, stopping`);
448
+ return { outcome: 'skipped-locked', trackedCanopyMeta };
449
+ };
450
+ // Idempotent, and applied every cycle so any clone lacking it gets it.
451
+ await ensureGitExcludePattern(basePath, `${CANOPY_META_DIR}/`);
427
452
  const baseGit = simpleGit({
428
453
  baseDir: basePath,
429
454
  // Keep git non-interactive during the merge so it never blocks on an
@@ -437,17 +462,43 @@ export async function refreshBaseBranchWorkspace(ctx) {
437
462
  // Inactivity-based, so a slow-but-flowing transfer is unaffected.
438
463
  timeout: { block: ctx.taskTimeoutMs },
439
464
  });
465
+ const trackedState = await listTrackedCanopyState(baseGit);
466
+ if (trackedState.length > 0) {
467
+ trackedCanopyMeta = trackedState.slice(0, MAX_REPORTED_PATHS);
468
+ const warnKey = `${basePath}\0${trackedState.join('\0')}`;
469
+ if (!warnedTrackedCanopyState.has(warnKey)) {
470
+ warnedTrackedCanopyState.add(warnKey);
471
+ workerLogWarn(`Base branch (${ctx.baseBranch}) tracks canopycms state that must not be committed: ` +
472
+ `${trackedState.join(', ')}. To fix, ${TRACKED_CANOPY_STATE_FIX}.`);
473
+ }
474
+ }
475
+ let status = await baseGit.status();
476
+ const lostBeforeRestore = lockLost();
477
+ if (lostBeforeRestore)
478
+ return lostBeforeRestore;
479
+ if (await restoreRetiredSchemaCache(baseGit, status)) {
480
+ workerLog(`Base branch workspace (${ctx.baseBranch}): restored the retired in-tree schema cache`);
481
+ status = await baseGit.status();
482
+ }
440
483
  // Nothing makes this clone read-only, and a direct edit here wedges every
441
484
  // 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 !== '?');
485
+ // tree is loud, not a quiet skip. Only TRACKED content changes block the
486
+ // refresh: canopycms's own state never does, and neither does a stray
487
+ // untracked file. A modified tracked file or an untracked one that would
488
+ // collide with incoming content makes the --ff-only merge below refuse and
489
+ // report `failed`; an IGNORED file, which every .canopy-meta file is, git
490
+ // overwrites instead.
491
+ const trackedDirty = status.files
492
+ .filter((f) => !isUntracked(f) && !isCanopyInternalPath(f.path))
493
+ .map((f) => f.path);
448
494
  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;
495
+ workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.join(', ')}`);
496
+ return {
497
+ outcome: 'skipped-dirty',
498
+ dirtyFiles: trackedDirty.slice(0, MAX_REPORTED_PATHS),
499
+ message: `${trackedDirty.length} uncommitted tracked file(s) in the base branch workspace`,
500
+ trackedCanopyMeta,
501
+ };
451
502
  }
452
503
  // Raw (unsanitized) name from here on: these are git ref operations
453
504
  // against origin/<baseBranch>, not filesystem paths, so they must use
@@ -462,12 +513,31 @@ export async function refreshBaseBranchWorkspace(ctx) {
462
513
  const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
463
514
  const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
464
515
  if (behindCount > 0) {
516
+ const lostBeforeMerge = lockLost();
517
+ if (lostBeforeMerge)
518
+ return lostBeforeMerge;
519
+ // Untrack, in the index only, any state the tip has stopped tracking:
520
+ // the merge would otherwise refuse to overwrite a modified copy, or
521
+ // delete a clean one from disk. Safe here and not in the rebase loop,
522
+ // because this clone has no commits of its own to replay. State still
523
+ // tracked upstream is left for the merge, which refuses where upstream
524
+ // changed a locally modified copy.
525
+ const { droppedUpstream } = await splitByUpstreamTracking(baseGit, trackedState, fetchedTip);
526
+ if (droppedUpstream.length > 0) {
527
+ await untrackInIndex(baseGit, droppedUpstream);
528
+ workerLog(`Base branch workspace (${ctx.baseBranch}): stopped tracking ${droppedUpstream.join(', ')}, as upstream has`);
529
+ }
465
530
  try {
466
531
  await baseGit.merge(['--ff-only', fetchedTip]);
467
532
  }
468
533
  catch (err) {
469
534
  workerLogError(`Base branch workspace (${ctx.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
470
- return;
535
+ return {
536
+ outcome: 'failed',
537
+ // [REDACT] Served to the browser by the admin panel.
538
+ message: redactCredentials(`failed to fast-forward: ${getErrorMessage(err)}`),
539
+ trackedCanopyMeta,
540
+ };
471
541
  }
472
542
  await invalidateBranchContentCaches(basePath);
473
543
  }
@@ -491,8 +561,25 @@ export async function refreshBaseBranchWorkspace(ctx) {
491
561
  workerLog(behindCount > 0
492
562
  ? `Base branch workspace (${ctx.baseBranch}): fast-forwarded ${behindCount} commit(s)`
493
563
  : `Base branch workspace (${ctx.baseBranch}): up to date`);
564
+ if (trackedCanopyMeta && behindCount > 0) {
565
+ const stillTracked = await listTrackedCanopyState(baseGit);
566
+ trackedCanopyMeta =
567
+ stillTracked.length > 0 ? stillTracked.slice(0, MAX_REPORTED_PATHS) : undefined;
568
+ }
569
+ return { outcome: behindCount > 0 ? 'refreshed' : 'up-to-date', trackedCanopyMeta };
494
570
  }
495
571
  catch (err) {
496
572
  workerLogError(`Base branch workspace (${ctx.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
573
+ // [REDACT] Served to the browser by the admin panel.
574
+ return {
575
+ outcome: 'failed',
576
+ message: redactCredentials(getErrorMessage(err)),
577
+ trackedCanopyMeta,
578
+ };
579
+ }
580
+ finally {
581
+ if (releaseProvisioning) {
582
+ await releaseProvisionedWorkspace(releaseProvisioning, ctx.sanitizedBaseBranch);
583
+ }
497
584
  }
498
585
  }
@@ -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 clones the workspace (`branch-workspace.ts`), so the worker never runs git
22
+ * against a half-made clone. 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`. The Lambda clones directly into the
30
+ * target path, so `.git` appears early, and writes `branch.json` only after the clone and its
31
+ * lock are done.
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 clones the workspace (`branch-workspace.ts`), so the worker never runs git
10
+ * against a half-made clone. 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`. The Lambda clones directly into the
18
+ * target path, so `.git` appears early, and writes `branch.json` only after the clone and its
19
+ * lock are done.
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
+ }
@@ -7,8 +7,10 @@ import { invalidateBranchContentCaches } from '../content-index-generation.js';
7
7
  import { normalizeFilesystemPath } from '../paths/normalize.js';
8
8
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
9
9
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
10
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
10
11
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
11
- import { isRebaseInProgress } from '../utils/git.js';
12
+ import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
13
+ import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
12
14
  import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
13
15
  import { workerLog, workerLogWarn } from './log.js';
14
16
  /**
@@ -123,7 +125,7 @@ const MAX_REBASE_ROUNDS = 50;
123
125
  * - the **unexpected-error** exit aborts HERE, before breaking, so the caller's
124
126
  * `!completed` abort is a caught no-op for that path;
125
127
  * - the **lock-compromised** exit leaves the rebase in progress and is aborted
126
- * by `rebaseOneBranch`'s own `contentLockCompromised && !completed` branch,
128
+ * by `rebaseOneBranch`'s own `lockCompromised() && !completed` branch,
127
129
  * which returns `skippedLocked` before the `!completed` block below it is
128
130
  * reached -- a distinct site that cannot be folded into that one;
129
131
  * - the **conflict-resolution-failure** and **MAX_REBASE_ROUNDS** exits are the
@@ -406,7 +408,7 @@ export async function runRebaseCycle(ctx) {
406
408
  workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
407
409
  continue;
408
410
  }
409
- const outcome = await rebaseOneBranch(ctx, branchDir, branchPath);
411
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath);
410
412
  if (outcome.kind === 'rebased')
411
413
  rebased.push(branchDir);
412
414
  else if (outcome.kind === 'skippedDirty')
@@ -423,6 +425,39 @@ export async function runRebaseCycle(ctx) {
423
425
  }
424
426
  return { rebased, skippedDirty, skippedLocked, failed };
425
427
  }
428
+ /**
429
+ * {@link rebaseOneBranch} under the branch's provisioning lock, so no git step races a clone of
430
+ * this directory. A branch whose lock is held elsewhere is `skippedLocked` and retried next cycle;
431
+ * one with no `branch.json` yet is skipped as not provisioned. Lock order: provisioning, then the
432
+ * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
433
+ * Never throws, like rebaseOneBranch.
434
+ */
435
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
436
+ let hold;
437
+ try {
438
+ hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
439
+ }
440
+ catch (err) {
441
+ // [REDACT] failed[] is served to the browser via worker-status.json.
442
+ const error = redactCredentials(`could not take the provisioning lock: ${getErrorMessage(err)}`);
443
+ workerLogWarn(` Skipping ${branchDir}: ${error}`);
444
+ return { kind: 'failed', error };
445
+ }
446
+ if (hold.kind === 'locked') {
447
+ workerLog(` Skipping ${branchDir}: provisioning lock held elsewhere (retrying next cycle)`);
448
+ return { kind: 'skippedLocked' };
449
+ }
450
+ if (hold.kind === 'not-provisioned') {
451
+ workerLog(` Skipping ${branchDir}: not yet provisioned (no branch.json)`);
452
+ return { kind: 'none' };
453
+ }
454
+ try {
455
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised);
456
+ }
457
+ finally {
458
+ await releaseProvisionedWorkspace(hold.release, branchDir);
459
+ }
460
+ }
426
461
  /**
427
462
  * Rebase ONE branch workspace onto the base branch, keeping the branch's
428
463
  * version of every conflicting file.
@@ -435,7 +470,7 @@ export async function runRebaseCycle(ctx) {
435
470
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
436
471
  * rest of the cycle ([SYNC-M2]).
437
472
  */
438
- async function rebaseOneBranch(ctx, branchDir, branchPath) {
473
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised) {
439
474
  // Read only by the catch below. See the `rebased` rider on
440
475
  // BranchRebaseOutcome.
441
476
  let didRebase = false;
@@ -489,6 +524,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
489
524
  // record it and bail before the next one rather than replaying over
490
525
  // an editor's concurrent save.
491
526
  let contentLockCompromised = false;
527
+ // Either lock lost means another writer may be live against this tree;
528
+ // both get the same bail-before-the-next-destructive-step treatment.
529
+ const lockCompromised = () => contentLockCompromised || isProvisioningLockCompromised();
492
530
  try {
493
531
  releaseContentLock = await tryAcquireContentWriteLock(branchPath, (lockErr) => {
494
532
  contentLockCompromised = true;
@@ -574,12 +612,37 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
574
612
  }
575
613
  // Skip dirty branches — the editor has changes that cannot be rebased.
576
614
  // Inside the lock, so no write can land between this check and the rebase
577
- // below.
578
- const dirtyCheck = await branchGit.status();
579
- if (dirtyCheck.files.length > 0) {
615
+ // below. canopycms's own untracked state is not dirt.
616
+ let dirtyCheck = await branchGit.status();
617
+ if (await restoreRetiredSchemaCache(branchGit, dirtyCheck)) {
618
+ workerLog(` ${branchDir}: restored the retired in-tree schema cache`);
619
+ dirtyCheck = await branchGit.status();
620
+ }
621
+ const editorDirt = dirtyCheck.files.filter((f) => !isCanopyInternalPath(f.path));
622
+ if (editorDirt.length > 0) {
580
623
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
581
624
  return { kind: 'skippedDirty' };
582
625
  }
626
+ // `git rebase` refuses to start over ANY modified tracked file, so
627
+ // tracked canopycms state blocks. The worker never untracks it here:
628
+ // replaying a branch commit that touched it writes historical bytes over
629
+ // the live file, and the abort that follows deletes it. So the index and
630
+ // the bytes stay untouched, and the wedge is recorded for an operator.
631
+ const trackedState = trackedCanopyStateChanges(dirtyCheck);
632
+ if (trackedState.length > 0) {
633
+ await branchGit.fetch('origin', ctx.baseBranch);
634
+ const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
635
+ const { stillTracked } = await splitByUpstreamTracking(branchGit, trackedState, baseTip);
636
+ const reason = stillTracked.length > 0
637
+ ? `git cannot rebase over modified canopycms state the repo tracks ` +
638
+ `(${trackedState.join(', ')}). To fix, ${TRACKED_CANOPY_STATE_FIX}.`
639
+ : `git cannot rebase over modified canopycms state this clone still tracks ` +
640
+ `(${trackedState.join(', ')}), though the base branch no longer does; ` +
641
+ `the clone needs manual repair`;
642
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
643
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason);
644
+ return { kind: 'failed', error: reason };
645
+ }
583
646
  // The clone's own ref name: branchDir is the sanitized DIRECTORY name and
584
647
  // need not match it. A literal 'HEAD' means a detached clone (a crashed
585
648
  // rebase can leave one), where nothing below can safely name a ref, so
@@ -651,7 +714,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
651
714
  // the rebase makes unreadable.
652
715
  const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
653
716
  const publishedSha = canPublish ? await readPublishedSha(ctx, branchRef) : null;
654
- const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, () => contentLockCompromised);
717
+ const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
655
718
  // Outside the round loop's try/catch, so a throwing test hook can
656
719
  // never be misread as a rebase error.
657
720
  if (completed)
@@ -676,14 +739,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath) {
676
739
  // history while a concurrent save is uncommitted working-tree state, and
677
740
  // that writer is already told to retry via ContentWriteLockBusyError. So
678
741
  // when the rebase completed, log the lost exclusivity loudly and finish.
679
- if (contentLockCompromised && !completed) {
680
- workerLogWarn(` Skipping ${branchDir}: content-write lock was compromised mid-rebase (retrying next cycle)`);
742
+ if (lockCompromised() && !completed) {
743
+ workerLogWarn(` Skipping ${branchDir}: a lock it held was compromised mid-rebase (retrying next cycle)`);
681
744
  // No-op when no rebase is in progress; failure is expected there.
682
745
  await branchGit.rebase(['--abort']).catch(() => { });
683
746
  return { kind: 'skippedLocked' };
684
747
  }
685
- if (contentLockCompromised) {
686
- workerLogWarn(` Content-write lock for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
748
+ if (lockCompromised()) {
749
+ workerLogWarn(` A lock held for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
687
750
  }
688
751
  if (!completed) {
689
752
  // failureReason is only set on the "unexpected error" break above;