canopycms 0.8.0 → 0.8.2

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 (86) hide show
  1. package/CHANGELOG.md +112 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { completeTask, dequeueTask, failTask, recoverOrphanedTasks, releaseTask, retryTask, } from '../task-queue/cms-task-queue.js';
3
3
  import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
4
- import { BranchMetadataCorruptError, BranchMetadataFileManager, getBranchMetadataFileManager, } from '../branch-metadata.js';
4
+ import { BranchMetadataCorruptError, BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
5
5
  import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
6
6
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
7
7
  import { isNonFastForwardRejection, workflowPushRefusalFile } from '../utils/git.js';
@@ -13,6 +13,7 @@ import { pushToGitHub } from './github-transfer.js';
13
13
  import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
14
14
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
15
15
  import { SYNC_PR_COMMENTS_ACTION, refreshCommentsBlock, renderPrCommentsBody } from '../pr-comments.js';
16
+ import { COMMIT_ID, REWRITTEN_PUSH_REFUSAL, SEEN_COMMIT_GONE_PUSH_REFUSAL, enqueueReviewBlockSync, } from './github-commits.js';
16
17
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
17
18
  import { assertSharedRepoConfig } from './shared-repo-git.js';
18
19
  /**
@@ -35,6 +36,15 @@ class TaskAbortedForShutdownError extends Error {
35
36
  */
36
37
  class NothingToSubmitTaskError extends PermanentTaskError {
37
38
  }
39
+ /** The task a submit queues (api/github-sync.ts). */
40
+ const SUBMIT_ACTION = 'push-and-create-or-update-pr';
41
+ /**
42
+ * GitHub's branch moved past what this deployment published. An immediate retry of the same push
43
+ * cannot land, but the next sync brings those commits in (worker/github-commits.ts), so a submit's
44
+ * task, which also updates the PR, runs again after it.
45
+ */
46
+ class GitHubMovedTaskError extends PermanentTaskError {
47
+ }
38
48
  /**
39
49
  * Classify a task failure as permanent (fail fast) or transient (retry).
40
50
  *
@@ -211,7 +221,14 @@ export async function processTaskQueue(ctx) {
211
221
  const permanent = isPermanentTaskFailure(err);
212
222
  const retryCount = task.retryCount ?? 0;
213
223
  const maxRetries = task.maxRetries ?? ctx.maxRetries;
214
- if (!permanent && retryCount < maxRetries) {
224
+ const waitsForSync = err instanceof GitHubMovedTaskError;
225
+ if (waitsForSync && retryCount < maxRetries) {
226
+ await retryTask(ctx.taskDir, task.id, persistedMessage, ctx.log, {
227
+ delayMs: ctx.gitSyncIntervalMs,
228
+ });
229
+ workerLog(` Will retry after the next sync (attempt ${retryCount + 1}/${maxRetries})`);
230
+ }
231
+ else if (!permanent && retryCount < maxRetries) {
215
232
  await retryTask(ctx.taskDir, task.id, persistedMessage, ctx.log);
216
233
  workerLog(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
217
234
  }
@@ -220,7 +237,7 @@ export async function processTaskQueue(ctx) {
220
237
  await updateBranchMetadataOnFailure(ctx, task, persistedMessage, {
221
238
  unlock: err instanceof NothingToSubmitTaskError,
222
239
  });
223
- workerLogError(permanent
240
+ workerLogError(permanent && !waitsForSync
224
241
  ? ' Permanently failed (non-retryable error)'
225
242
  : ` Permanently failed after ${maxRetries} retries`);
226
243
  }
@@ -315,6 +332,10 @@ export async function executeTask(ctx, task, signal) {
315
332
  // list->tiebreak->update/create logic and the draft->ready conversion
316
333
  // live in one place.
317
334
  const branch = requireString(payload, 'branch');
335
+ if (await isSupersededSubmit(ctx, task, branch)) {
336
+ workerLog(`Not retrying the submit of ${branch}: the branch has moved on from it`);
337
+ return { superseded: true };
338
+ }
318
339
  const base = optionalString(payload, 'baseBranch', ctx.baseBranch);
319
340
  // Defense-in-depth: refuse head===base even if the 'submittableBranch'
320
341
  // API guard and the syncSubmitPr backstop were both bypassed.
@@ -460,6 +481,7 @@ export async function executeTask(ctx, task, signal) {
460
481
  branch: editorBranchOf(payload, branch),
461
482
  editorUrl,
462
483
  body: pr.body,
484
+ reviewBlockOnly: payload.reviewBlockOnly === true,
463
485
  warn: workerLogWarn,
464
486
  });
465
487
  if (body === undefined)
@@ -472,6 +494,34 @@ export async function executeTask(ctx, task, signal) {
472
494
  throw new PermanentTaskError(`Unknown task action: ${action}`);
473
495
  }
474
496
  }
497
+ /**
498
+ * Whether a submit no longer describes the branch: it merged, so running it would open a pull
499
+ * request nothing tracks; it was withdrawn or sent back, so running it would ready the PR someone
500
+ * pulled back; or it was submitted again, so running it would overwrite the newer title and body.
501
+ * A first run can start before the API has recorded the submit it queued, which reads as a branch
502
+ * still holding an older one. A drain's release and an orphan's recovery keep `retryCount`, so a
503
+ * first run can also start long after, and only `submittedAt` tells the two apart.
504
+ */
505
+ async function isSupersededSubmit(ctx, task, branch) {
506
+ const { submittedAt, requeuedFrom } = task.payload;
507
+ if (typeof submittedAt !== 'string')
508
+ return false;
509
+ const live = await BranchMetadataFileManager.loadOnly(ctx.branchWorkspacePath(branch));
510
+ if (live === null)
511
+ return false;
512
+ const { status, submittedAt: recorded } = live.branch;
513
+ if (status === 'archived')
514
+ return true;
515
+ const firstRun = !task.retryCount && requeuedFrom === undefined;
516
+ if (firstRun && (recorded === undefined || recorded < submittedAt))
517
+ return false;
518
+ return (status !== 'submitted' && status !== 'approved') || recorded !== submittedAt;
519
+ }
520
+ /** A submit that isn't the branch's latest, whose outcome says nothing about the latest one. */
521
+ function isOlderSubmit(task, recorded) {
522
+ const { submittedAt } = task.payload;
523
+ return (task.action === SUBMIT_ACTION && typeof submittedAt === 'string' && recorded !== submittedAt);
524
+ }
475
525
  /**
476
526
  * The name links and a fallback title give the branch: the short name the API queued
477
527
  * (branch-param.ts), else the ref, which the editor resolves too.
@@ -515,35 +565,66 @@ export async function updateBranchMetadata(ctx, task, result) {
515
565
  }
516
566
  try {
517
567
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
518
- const saved = await meta.update((existing) => {
568
+ // A superseded submit synced nothing. A branch taken back to editing waits on no other task
569
+ // and owes no pull request step, so it would read as syncing or failed until its next submit.
570
+ // An archived one has no next submit, and would read as syncing or failed for good.
571
+ if (result.superseded === true) {
572
+ await meta.update((existing) => existing?.branch.name === branch &&
573
+ (existing.branch.status === 'editing'
574
+ ? existing.branch.syncStatus === 'pending-sync' ||
575
+ existing.branch.submitUnfinished === true
576
+ : existing.branch.status === 'archived' &&
577
+ (existing.branch.syncStatus === 'pending-sync' ||
578
+ existing.branch.syncStatus === 'sync-failed'))
579
+ ? {
580
+ branch: {
581
+ syncStatus: 'synced',
582
+ syncFailureReason: undefined,
583
+ submitUnfinished: undefined,
584
+ },
585
+ }
586
+ : null, { keepUpdatedAt: true });
587
+ return;
588
+ }
589
+ let found = false;
590
+ // Bookkeeping: the save that queued this already said when the branch last changed.
591
+ await meta.update((existing) => {
519
592
  if (existing?.branch.name !== branch)
520
593
  return null;
521
- const updates = {
522
- syncStatus: 'synced',
523
- // save()'s merge only overwrites keys present in this update, so a prior
524
- // syncFailureReason would survive this successful sync forever unless
525
- // cleared explicitly (same pattern as rebaseFailure elsewhere).
526
- syncFailureReason: undefined,
527
- };
528
- if (result.prUrl)
594
+ found = true;
595
+ const updates = isOlderSubmit(task, existing.branch.submittedAt) ||
596
+ (task.action !== SUBMIT_ACTION && awaitsSubmitRetry(existing.branch))
597
+ ? {}
598
+ : {
599
+ syncStatus: 'synced',
600
+ // save()'s merge only overwrites keys present in this update, so a prior
601
+ // syncFailureReason would survive this successful sync forever unless
602
+ // cleared explicitly (same pattern as rebaseFailure elsewhere).
603
+ syncFailureReason: undefined,
604
+ submitUnfinished: undefined,
605
+ };
606
+ // A pull request opened as the branch merged is not this branch's, whose record is the merge.
607
+ const archived = existing.branch.status === 'archived';
608
+ if (archived && result.prNumber) {
609
+ workerLogWarn(`Not recording pull request #${result.prNumber} on merged ${branch}`);
610
+ }
611
+ if (result.prUrl && !archived)
529
612
  updates.pullRequestUrl = result.prUrl;
530
- if (result.prNumber) {
613
+ if (result.prNumber && !archived) {
531
614
  updates.pullRequestNumber = result.prNumber;
532
615
  // As soon as the PR exists the field reads 'open', rather than staying
533
616
  // absent until the next poll cycle observes it. Exception: the git-sync
534
- // loop may have archived this branch (PR merged/closed) while the task
535
- // was in flight, and a late completion must not downgrade that terminal
536
- // state back to 'open'.
537
- const terminal = existing.branch.status === 'archived' ||
538
- existing.branch.pullRequestState === 'merged' ||
617
+ // loop may have closed this PR while the task was in flight, and a late
618
+ // completion must not downgrade that terminal state back to 'open'.
619
+ const terminal = existing.branch.pullRequestState === 'merged' ||
539
620
  existing.branch.pullRequestState === 'closed';
540
621
  if (!terminal) {
541
622
  updates.pullRequestState = 'open';
542
623
  }
543
624
  }
544
- return { branch: updates };
545
- });
546
- if (saved === null)
625
+ return Object.keys(updates).length > 0 ? { branch: updates } : null;
626
+ }, { keepUpdatedAt: true });
627
+ if (!found)
547
628
  noMetadataToRecord(branch, `${task.action} outcome`);
548
629
  }
549
630
  catch (err) {
@@ -588,9 +669,39 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
588
669
  existing.branch.pullRequestNumber === undefined &&
589
670
  existing.branch.submittedAt === submittedAt)) !== null;
590
671
  if (!unlocked) {
591
- const saved = await meta.saveIf({ branch: failed }, (existing) => existing?.branch.name === branch);
592
- if (saved === null)
672
+ let found = false;
673
+ let revoked = false;
674
+ const saved = await meta.update((existing) => {
675
+ if (existing?.branch.name !== branch)
676
+ return null;
677
+ found = true;
678
+ // An older submit running late has no step left to finish, and what it failed at says
679
+ // nothing about the latest one.
680
+ if (isOlderSubmit(task, existing.branch.submittedAt))
681
+ return null;
682
+ // Nothing to submit means this submit left nothing out, so it flags nothing. A flag a
683
+ // merge already set stands, for an admin to dismiss.
684
+ if (options.unlock || task.action !== SUBMIT_ACTION)
685
+ return { branch: failed };
686
+ // Merged while this ran: nothing is left to finish it, as buildMergedBranchUpdate records.
687
+ if (existing.branch.status === 'archived') {
688
+ return { branch: { ...failed, unpublishedAtMerge: true } };
689
+ }
690
+ // An approval can land while a submit is pending. It covers a pull request that won't
691
+ // get what was approved, as for commits from GitHub (github-commits.ts).
692
+ revoked = existing.branch.status === 'approved';
693
+ return {
694
+ branch: {
695
+ ...failed,
696
+ submitUnfinished: true,
697
+ ...(revoked ? { status: 'submitted', review: undefined } : {}),
698
+ },
699
+ };
700
+ }, { keepUpdatedAt: true });
701
+ if (!found)
593
702
  noMetadataToRecord(branch, `${task.action} failure`);
703
+ if (revoked && saved !== null)
704
+ await enqueueReviewBlockSync(ctx, branch, saved);
594
705
  }
595
706
  }
596
707
  catch (err) {
@@ -642,8 +753,18 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
642
753
  // that commit: it moves GitHub off the commit we rewrote away, and
643
754
  // refuses in every other case (including a commit someone else pushed).
644
755
  const branchPath = ctx.branchWorkspacePath(branch);
645
- const metaFile = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
756
+ // An unreadable branch.json throws: without it nothing could say a push is safe.
757
+ const readMetadata = () => BranchMetadataFileManager.loadOnly(branchPath).catch((err) => {
758
+ throw err instanceof BranchMetadataCorruptError ? new Error(err.parseCause) : err;
759
+ });
760
+ const metaFile = await readMetadata();
646
761
  const marker = metaFile?.branch.historyRewrittenFrom;
762
+ // github-commits.ts recorded it: this push would restore what someone removed from GitHub.
763
+ if (metaFile?.branch.rewrittenOnGitHub !== undefined) {
764
+ workerLogWarn(`Not pushing ${branch}: GitHub's branch no longer contains ` +
765
+ `${metaFile.branch.rewrittenOnGitHub}`);
766
+ throw new PermanentTaskError(REWRITTEN_PUSH_REFUSAL);
767
+ }
647
768
  const readSha = async () => {
648
769
  const sha = await readPublishedSha(ctx, branch);
649
770
  if (sha === null) {
@@ -651,6 +772,14 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
651
772
  }
652
773
  return sha;
653
774
  };
775
+ // Read after the commit it goes with, each attempt: a merge records what it took from GitHub
776
+ // before `remote.git` holds it, so this names everything that commit brought in. A push under a
777
+ // marker has a lease instead, and the marker's stamp names the commit it's taking to GitHub. One
778
+ // armed since this task began pushes without that lease, so it keeps the check.
779
+ const readSeen = async () => {
780
+ const seen = (await readMetadata())?.branch.lastGitHubTip;
781
+ return marker === undefined && seen !== undefined && COMMIT_ID.test(seen) ? seen : undefined;
782
+ };
654
783
  // The commit GitHub was sent: the transfer reads it again if the branch moves under it.
655
784
  let outgoingSha;
656
785
  let outcome;
@@ -659,6 +788,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
659
788
  ({ sha: outgoingSha, outcome } = await pushToGitHub(ctx, {
660
789
  branch,
661
790
  lease: marker,
791
+ readSeen,
662
792
  prerequisiteBranches: [branch, ctx.baseBranch],
663
793
  }, readSha, signal));
664
794
  }
@@ -668,12 +798,17 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
668
798
  throw new PermanentTaskError(err.message);
669
799
  if (!(err instanceof GitHubPushError))
670
800
  throw err;
801
+ // The next sync sees why, and records the rewrite if that's what it was.
802
+ if (err.kind === 'seen-commit-gone') {
803
+ workerLogWarn(`Not pushing ${branch}: ${err.message}`);
804
+ throw new GitHubMovedTaskError(SEEN_COMMIT_GONE_PUSH_REFUSAL);
805
+ }
671
806
  throwIfWorkflowRefusal(branch, err.message);
672
807
  // A refused lease was already retried plain, and that retry rejected too: only a
673
808
  // non-fast-forward then means the branch has genuinely diverged.
674
809
  if (err.kind === 'rejected-after-stale-lease') {
675
810
  if (isNonFastForwardRejection(err.message)) {
676
- throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
811
+ throw new GitHubMovedTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
677
812
  `deployment last published nor an ancestor of what it is pushing, so the branch ` +
678
813
  `has genuinely diverged and nothing was overwritten. Something else moved it on ` +
679
814
  `GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
@@ -687,7 +822,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
687
822
  // advise renaming the branch: one reaching this point usually has an open
688
823
  // PR, which renaming would orphan.
689
824
  if (isNonFastForwardRejection(err.message)) {
690
- throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
825
+ throw new GitHubMovedTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
691
826
  `published, so the branch has diverged and needs reconciling. Something else moved it ` +
692
827
  `on GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
693
828
  }
@@ -697,7 +832,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
697
832
  // The lease was refused, so GitHub is provably not at the marker: it
698
833
  // has moved past the rewritten commit and the marker is spent.
699
834
  await clearHistoryRewrittenMarker(ctx, branchPath, branch);
700
- await recordPushedToGitHub(ctx, branchPath, branch);
835
+ await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
701
836
  logPushOutcome(ctx, branch, outcome, 'GitHub had already moved past the rewritten commit');
702
837
  return;
703
838
  }
@@ -708,7 +843,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
708
843
  if (marker && outgoingSha !== marker) {
709
844
  await clearHistoryRewrittenMarker(ctx, branchPath, branch);
710
845
  }
711
- await recordPushedToGitHub(ctx, branchPath, branch);
846
+ await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
712
847
  logPushOutcome(ctx, branch, outcome);
713
848
  }
714
849
  /** An unmoved ref is routine (a task re-run after a crash), so it is logged at debug level. */
@@ -722,12 +857,13 @@ function logPushOutcome(ctx, branch, outcome, note) {
722
857
  }
723
858
  }
724
859
  /**
725
- * Stamp `pushedToGitHubAt`, branch delete's proof that the GitHub branch is this one. Recorded
726
- * when the push lands, whatever the PR call after it does. Best-effort: a branch deleted
727
- * meanwhile has no metadata to stamp, and a failed stamp only leaves a later delete to skip
728
- * GitHub.
860
+ * Stamp `pushedToGitHubAt`, branch delete's proof that the GitHub branch is this one, and
861
+ * `lastGitHubTip`, what github-commits.ts tells a rewrite on GitHub by. Recorded when the push
862
+ * lands, whatever the PR call after it does. Best-effort: a branch deleted meanwhile has no
863
+ * metadata to stamp, and a failed stamp only leaves a later delete to skip GitHub. Bookkeeping, so
864
+ * it leaves `updatedAt`, which the card shows as an edit.
729
865
  */
730
- async function recordPushedToGitHub(ctx, branchPath, branch) {
866
+ async function recordPushedToGitHub(ctx, branchPath, branch, sha) {
731
867
  if (isSettingsBranch(branch))
732
868
  return;
733
869
  try {
@@ -737,7 +873,9 @@ async function recordPushedToGitHub(ctx, branchPath, branch) {
737
873
  return;
738
874
  }
739
875
  try {
740
- const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { pushedToGitHubAt: new Date().toISOString() } }, (existing) => existing?.branch.name === branch);
876
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branch
877
+ ? { branch: { pushedToGitHubAt: new Date().toISOString(), lastGitHubTip: sha } }
878
+ : null, { keepUpdatedAt: true });
741
879
  if (saved === null)
742
880
  noMetadataToRecord(branch, 'GitHub push');
743
881
  }
@@ -50,6 +50,8 @@ export interface WorkerContext {
50
50
  readonly schemaHoldMaxMs: number;
51
51
  /** Per-task timeout in ms; also simple-git's inactivity block timeout. */
52
52
  readonly taskTimeoutMs: number;
53
+ /** How long the sync loop rests between cycles, in ms. */
54
+ readonly gitSyncIntervalMs: number;
53
55
  /** Max tasks to process per `processTaskQueue` cycle. */
54
56
  readonly maxTasksPerCycle: number;
55
57
  /** Default max retries for a failed task that does not carry its own. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.8.0",
4
+ "version": "0.8.2",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {