canopycms 0.12.1 → 0.13.0

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 (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -260,9 +260,14 @@ async function carryForwardRewrittenHistory(ctx, args) {
260
260
  `${branchRef}, so the rewrite is not published`);
261
261
  return;
262
262
  }
263
- if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha)) {
263
+ const publish = await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha, rebasedHead);
264
+ if (publish.kind === 'published') {
264
265
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
265
266
  }
267
+ else if (publish.kind === 'failed') {
268
+ const reason = redactCredentials(`this branch's rebased history could not be published into the deployment's repository: ${publish.message}`);
269
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
270
+ }
266
271
  }
267
272
  else {
268
273
  const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
@@ -296,13 +301,13 @@ function worthAnotherCycle(branch) {
296
301
  }
297
302
  /**
298
303
  * Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
299
- * wait. Only from a cycle whose GitHub fetch began at or after the request, and only once the
300
- * branch holds the base that fetch found: an earlier fetch may predate the changes asked for, and a
301
- * base ref that didn't move (a held or failed update) isn't the latest. While the schema gate holds
302
- * the base the request stands. Anything else that settles nothing, a lock or a GitHub step on its
303
- * way among them, asks for another early cycle while the request is recent. Never throws.
304
+ * wait. Only from a fetch begun at or after the request, and only once the branch holds the base
305
+ * that fetch found: an earlier fetch may predate the changes asked for, and a base ref that didn't
306
+ * move (a held or failed update) isn't the latest. While the schema gate holds the base the request
307
+ * stands. Anything else that settles nothing, a lock or a GitHub step on its way among them, asks
308
+ * for another early cycle while the request is recent. Never throws.
304
309
  */
305
- async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested) {
310
+ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested) {
306
311
  const { request } = probe;
307
312
  if (!request) {
308
313
  // Skipped before its request was read: a provisioning lock held elsewhere.
@@ -313,7 +318,7 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
313
318
  }
314
319
  return;
315
320
  }
316
- if (!gitHub || ctx.ensureStatusReport().baseHold)
321
+ if (!source || ctx.ensureStatusReport().baseHold)
317
322
  return;
318
323
  const askAgain = () => {
319
324
  if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
@@ -321,17 +326,16 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
321
326
  }
322
327
  };
323
328
  // The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
324
- if (gitHub.fetchedAt < Date.parse(request.requestedAt))
329
+ if (source.fetchedAt < Date.parse(request.requestedAt))
325
330
  return askAgain();
326
331
  const answer = answerFor(outcome, probe);
327
332
  if (answer === undefined)
328
333
  return askAgain();
329
- const gitHubBase = gitHub.tips.get(ctx.baseBranch);
330
- if (answer !== 'failed' && gitHubBase !== undefined && probe.baseSha !== gitHubBase) {
334
+ if (answer !== 'failed' && source.baseTip !== undefined && probe.baseSha !== source.baseTip) {
331
335
  return askAgain();
332
336
  }
333
337
  const lastBaseUpdate = {
334
- at: new Date(gitHub.fetchedAt).toISOString(),
338
+ at: new Date(source.fetchedAt).toISOString(),
335
339
  outcome: answer,
336
340
  ...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
337
341
  ...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
@@ -350,19 +354,32 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
350
354
  workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
351
355
  }
352
356
  }
357
+ /** A run with no GitHub publishes nothing there: an empty namespace is a gateway that pushes nothing. */
358
+ const NO_GITHUB_POLICY = {
359
+ configured: [],
360
+ githubDefault: null,
361
+ namespace: '',
362
+ legacy: [],
363
+ settingsBranch: null,
364
+ };
365
+ /** The GitHub fetch's answer source: when it began, and the base it found. */
366
+ const sourceOf = (ctx, gitHub) => ({
367
+ fetchedAt: gitHub.fetchedAt,
368
+ baseTip: gitHub.tips.get(ctx.baseBranch),
369
+ });
353
370
  /**
354
371
  * Rebase every branch workspace that is behind the base branch, one cycle.
355
372
  *
356
- * The per-branch work is {@link rebaseOneBranch}; what stays here is the walk
357
- * over `content-branches/`, the structural pre-filter (entries that are not
358
- * branch workspaces at all, plus the base branch's own clone), and folding
359
- * each branch's outcome into the summary.
373
+ * The per-branch work is {@link syncBranchDir}; what stays here is the walk
374
+ * over `content-branches/` and folding each branch's outcome into the summary.
375
+ * `source` answers requests for the latest changes; the GitHub fetch's by default. `local` is a
376
+ * run with no GitHub (see {@link syncBranchDir}).
360
377
  *
361
378
  * Deliberately sequential rather than concurrent: each branch's rebase holds
362
379
  * that branch's cross-host content-write lock and runs a series of git
363
380
  * subprocesses against a shared filesystem.
364
381
  */
365
- export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set()) {
382
+ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set(), source = gitHub && sourceOf(ctx, gitHub), local = false) {
366
383
  const rebased = [];
367
384
  const skippedDirty = [];
368
385
  const skippedLocked = [];
@@ -383,47 +400,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new
383
400
  workerLog(' Rebase cycle stopped: the worker is draining');
384
401
  break;
385
402
  }
386
- // Known structural entries under content-branches/, not branch workspaces:
387
- // branches.json (registry snapshot) and dot-prefixed entries
388
- // (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
389
- // skip logs below don't fire for them every cycle; everything else still
390
- // logs loudly on skip.
391
- if (branchDir.startsWith('.') || branchDir === 'branches.json') {
392
- continue;
393
- }
394
- const branchPath = path.join(ctx.contentBranchesPath, branchDir);
395
- const gitDir = path.join(branchPath, '.git');
396
- try {
397
- const stat = await fs.stat(gitDir);
398
- if (!stat.isDirectory()) {
399
- workerLog(` Skipping ${branchDir}: .git is not a directory`);
400
- continue;
401
- }
402
- }
403
- catch {
404
- workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
405
- continue;
406
- }
407
- // The base branch's own clone is refreshed ff-only by
408
- // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
409
- // this loop could rewrite its history (the rebase below) and stamp
410
- // meaningless conflict metadata on it. Compare
411
- // sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
412
- // is raw.
413
- if (branchDir === ctx.sanitizedBaseBranch) {
414
- workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
403
+ const outcome = await syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested.has(branchDir), {}, local);
404
+ if (outcome === null)
415
405
  continue;
416
- }
417
- // A settings branch is an orphan: rebasing it onto the base would give it content
418
- // history. The API refuses to provision one, so a directory under that name is a
419
- // leftover and is left alone.
420
- if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
421
- workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
422
- continue;
423
- }
424
- const probe = {};
425
- const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe);
426
- await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested.has(branchDir));
427
406
  if (outcome.kind === 'rebased')
428
407
  rebased.push(branchDir);
429
408
  else if (outcome.kind === 'skippedDirty')
@@ -440,6 +419,82 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new
440
419
  }
441
420
  return { rebased, skippedDirty, skippedLocked, failed };
442
421
  }
422
+ /**
423
+ * One entry of `content-branches/`: the structural pre-filter (entries that are not branch
424
+ * workspaces at all, the base branch's own clone, a settings branch), then
425
+ * {@link holdAndRebaseOneBranch} and the answer to its request. Null for an entry skipped before
426
+ * any of that. Never throws. A `local` run has no GitHub: it publishes nothing there, and
427
+ * `remote.git` is where a branch's work is published.
428
+ */
429
+ async function syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested, probe, local = false) {
430
+ // Known structural entries under content-branches/, not branch workspaces:
431
+ // branches.json (registry snapshot) and dot-prefixed entries
432
+ // (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
433
+ // skip logs below don't fire for them every cycle; everything else still
434
+ // logs loudly on skip.
435
+ if (branchDir.startsWith('.') || branchDir === 'branches.json')
436
+ return null;
437
+ const branchPath = path.join(ctx.contentBranchesPath, branchDir);
438
+ const gitDir = path.join(branchPath, '.git');
439
+ try {
440
+ const stat = await fs.stat(gitDir);
441
+ if (!stat.isDirectory()) {
442
+ workerLog(` Skipping ${branchDir}: .git is not a directory`);
443
+ return null;
444
+ }
445
+ }
446
+ catch {
447
+ workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
448
+ return null;
449
+ }
450
+ // The base branch's own clone is refreshed ff-only by
451
+ // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
452
+ // this loop could rewrite its history (the rebase below) and stamp
453
+ // meaningless conflict metadata on it. Compare
454
+ // sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
455
+ // is raw.
456
+ if (branchDir === ctx.sanitizedBaseBranch) {
457
+ workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
458
+ return null;
459
+ }
460
+ // A settings branch is an orphan: rebasing it onto the base would give it content
461
+ // history. The API refuses to provision one, so a directory under that name is a
462
+ // leftover and is left alone.
463
+ if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
464
+ workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
465
+ return null;
466
+ }
467
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, local ? NO_GITHUB_POLICY : gatewayPolicy, gitHub, probe, local);
468
+ await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested);
469
+ return outcome;
470
+ }
471
+ /**
472
+ * Bring the base into one branch now and answer its request for the latest changes, for a mode that
473
+ * runs no worker loop (operating-mode `baseUpdateRunsInRequest`). With no GitHub, the base is
474
+ * `remote.git`'s, so the answer is from this run's start. A run that answers nothing writes
475
+ * `failed`, since nothing else ever will: `busy` says a lock held it back, which a retry in a moment
476
+ * gets past. Never throws but for the metadata write of that fallback.
477
+ */
478
+ export async function syncBranchNow(ctx, branchName) {
479
+ const startedAt = Date.now();
480
+ const branchDir = sanitizeBranchName(branchName);
481
+ const probe = {};
482
+ const outcome = await syncBranchDir(ctx, branchDir, undefined, undefined, { fetchedAt: startedAt }, true, probe, true);
483
+ const busy = outcome?.kind === 'skippedLocked';
484
+ const branchPath = path.join(ctx.contentBranchesPath, branchDir);
485
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchName &&
486
+ existing.branch.status === 'editing' &&
487
+ existing.branch.baseUpdateRequestedAt !== undefined &&
488
+ Date.parse(existing.branch.baseUpdateRequestedAt) <= startedAt &&
489
+ isBaseUpdatePending(existing.branch)
490
+ ? {
491
+ branch: {
492
+ lastBaseUpdate: { at: new Date(startedAt).toISOString(), outcome: 'failed' },
493
+ },
494
+ }
495
+ : null, { keepUpdatedAt: true });
496
+ return busy ? 'busy' : 'answered';
497
+ }
443
498
  /**
444
499
  * The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
445
500
  * file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
@@ -491,7 +546,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
491
546
  * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
492
547
  * Never throws, like rebaseOneBranch.
493
548
  */
494
- async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe) {
549
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe, local) {
495
550
  let hold;
496
551
  try {
497
552
  hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
@@ -511,18 +566,62 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
511
566
  if (hold.kind === 'not-provisioned')
512
567
  return { kind: 'none' };
513
568
  try {
514
- return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe);
569
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe, local);
515
570
  }
516
571
  finally {
517
572
  await releaseProvisionedWorkspace(hold.release, branchDir);
518
573
  }
519
574
  }
520
- /** Clear a recorded failure and any recorded rewrite: the caller knows neither stands. */
521
- async function clearRebaseFailure(ctx, branchPath, branchRef) {
575
+ /**
576
+ * Clear a recorded failure and any recorded rewrite, the caller knowing neither stands, with
577
+ * `alsoClear` in the same write.
578
+ */
579
+ async function clearRebaseFailure(ctx, branchPath, branchRef, alsoClear = {}) {
522
580
  await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
523
- ? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
581
+ ? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch), ...alsoClear } }
524
582
  : null);
525
583
  }
584
+ /**
585
+ * {@link recordLatestChangesWaiting} for a branch the sync skips for its saved work: marked while it
586
+ * lacks the base `remote.git` holds. Read from `remote.git` rather than fetched, so the skip moves no
587
+ * ref. Best-effort, so the skip stays a skip.
588
+ */
589
+ async function noteLatestChangesWaiting(ctx, git, branchPath, metaFile) {
590
+ if (metaFile.branch.status !== 'editing')
591
+ return;
592
+ let baseTip;
593
+ let behind;
594
+ try {
595
+ baseTip = await readPublishedSha(ctx, ctx.baseBranch);
596
+ behind = baseTip !== null && !(await contains(git, 'HEAD', baseTip));
597
+ }
598
+ catch (err) {
599
+ workerLogWarn(` Could not compare ${escapeForLog(metaFile.branch.name)} with the base: ${getErrorMessage(err)}`);
600
+ return;
601
+ }
602
+ await recordLatestChangesWaiting(ctx, branchPath, metaFile, behind ? (baseTip ?? undefined) : undefined);
603
+ }
604
+ /**
605
+ * Mark the branch `metaFile` names as lacking `baseSha`, the base commit the sync first left out, or
606
+ * clear the mark when undefined: the automatic sync never commits saved work, so a branch holding
607
+ * some waits for its author's "Get latest changes" (base-update.ts). Written only when the mark comes
608
+ * or goes, as every save regenerates the branch registry, and as bookkeeping, which leaves
609
+ * `updatedAt`. Best-effort: a failure leaves the hint as it was until the next cycle.
610
+ */
611
+ async function recordLatestChangesWaiting(ctx, branchPath, metaFile, baseSha) {
612
+ const { name } = metaFile.branch;
613
+ const flips = (current) => (current === undefined) !== (baseSha === undefined);
614
+ if (!flips(metaFile.branch.latestChangesWaiting))
615
+ return;
616
+ try {
617
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === name && flips(current.branch.latestChangesWaiting)
618
+ ? { branch: { latestChangesWaiting: baseSha } }
619
+ : null, { keepUpdatedAt: true });
620
+ }
621
+ catch (err) {
622
+ workerLogWarn(` Could not record the latest changes waiting for ${escapeForLog(name)}: ${getErrorMessage(err)}`);
623
+ }
624
+ }
526
625
  /** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
527
626
  async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
528
627
  // [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
@@ -809,7 +908,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
809
908
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
810
909
  * rest of the cycle ([SYNC-M2]).
811
910
  */
812
- async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}) {
911
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}, local = false) {
813
912
  // Read only by the catch below. See the `rebased` rider on
814
913
  // BranchRebaseOutcome.
815
914
  let didRebase = false;
@@ -832,6 +931,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
832
931
  // - archived: finished, with no PR left to poll.
833
932
  if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
834
933
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
934
+ // Submit committed the saved work; a withdraw lets the next skip mark it again.
935
+ await recordLatestChangesWaiting(ctx, branchPath, metaFile);
835
936
  await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
836
937
  const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
837
938
  const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
@@ -999,6 +1100,27 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
999
1100
  lockLost: lockCompromised,
1000
1101
  }
1001
1102
  : null;
1103
+ const skipIfDirty = async () => {
1104
+ if (editorDirt.length === 0)
1105
+ return { kind: 'none' };
1106
+ if (metaFile !== null)
1107
+ await noteLatestChangesWaiting(ctx, branchGit, branchPath, metaFile);
1108
+ return { kind: 'skippedDirty' };
1109
+ };
1110
+ // A merge of GitHub's commits whose result holds the base `remote.git` holds answers a request
1111
+ // for the latest changes as a merge of the base does, conflicts included.
1112
+ const tookInByGitHubMerge = async (keptEntries) => {
1113
+ if (editorDirt.length > 0)
1114
+ return await skipIfDirty();
1115
+ const baseTip = await readPublishedSha(ctx, ctx.baseBranch).catch(() => null);
1116
+ const holdsBase = baseTip !== null && (await contains(branchGit, 'HEAD', baseTip).catch(() => false));
1117
+ if (!holdsBase)
1118
+ return { kind: 'none' };
1119
+ probe.baseSha = baseTip;
1120
+ probe.keptEntries = keptEntries;
1121
+ await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
1122
+ return { kind: 'rebased' };
1123
+ };
1002
1124
  // Before the dirty return: a fast-forward passes saves that touch other files.
1003
1125
  let unpushed = false;
1004
1126
  if (commitArgs !== null) {
@@ -1008,20 +1130,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1008
1130
  }
1009
1131
  if (outcome.kind === 'unpushed')
1010
1132
  unpushed = true;
1011
- else if (outcome.kind !== 'in-step') {
1012
- return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
1013
- }
1133
+ else if (outcome.kind === 'merged')
1134
+ return await tookInByGitHubMerge(outcome.keptEntries);
1135
+ else if (outcome.kind !== 'in-step')
1136
+ return await skipIfDirty();
1014
1137
  // A merge of the base this worker published and never moved the clone to comes first.
1015
1138
  const caughtUp = await catchUpWithPublished(ctx, commitArgs);
1016
1139
  if (caughtUp?.kind === 'blocked') {
1017
1140
  return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
1018
1141
  }
1019
1142
  if (caughtUp)
1020
- return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
1143
+ return await skipIfDirty();
1021
1144
  }
1022
1145
  if (editorDirt.length > 0) {
1023
1146
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
1024
- return { kind: 'skippedDirty' };
1147
+ return await skipIfDirty();
1025
1148
  }
1026
1149
  // `git rebase` refuses to start over ANY modified tracked file, so
1027
1150
  // tracked canopycms state blocks. The worker never untracks it here:
@@ -1043,21 +1166,32 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1043
1166
  await recordRebaseFailure(ctx, branchPath, branchDir, reason);
1044
1167
  return { kind: 'failed', error: reason };
1045
1168
  }
1046
- // [SYNC-H1] Self-heal: finish an interrupted publish even when this
1047
- // branch is not behind base. Every crash window in the arming sequence
1048
- // below leaves the marker set with the work unfinished, and this is what
1049
- // completes it -- without it one lost lease race strands the branch until
1050
- // the base happens to advance again. Gated on the loop-top snapshot, so
1051
- // unmarked branches (nearly all of them, every cycle) cost nothing.
1169
+ // [SYNC-H1] Self-heal: finish a publish that never left remote.git at
1170
+ // the marker's commit, even when this branch is not behind base -- a
1171
+ // crash in the arming sequence below, or a failed push. Gated on the
1172
+ // loop-top snapshot, so unmarked branches (nearly all of them, every
1173
+ // cycle) cost nothing.
1052
1174
  if (canPublish && metaFile?.branch.historyRewrittenFrom) {
1053
- await reconcilePendingRewrite(ctx, {
1175
+ const pending = await reconcilePendingRewrite(ctx, {
1054
1176
  branchPath,
1055
1177
  branchDir,
1056
1178
  branchRef,
1057
1179
  headSha: (await branchGit.revparse(['HEAD'])).trim(),
1058
1180
  marker: metaFile.branch.historyRewrittenFrom,
1181
+ target: metaFile.branch.historyRewrittenTo,
1059
1182
  gatewayPolicy,
1060
1183
  });
1184
+ // A rebase now would replace the rewrite remote.git still waits for, along with any commit
1185
+ // made on top of it since, which only a submit may publish.
1186
+ if (pending.kind === 'failed') {
1187
+ // [REDACT] failed[] is served to the browser via worker-status.json.
1188
+ const reason = redactCredentials(pending.reason);
1189
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
1190
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
1191
+ return { kind: 'failed', error: reason };
1192
+ }
1193
+ if (pending.kind === 'waiting')
1194
+ return { kind: 'none' };
1061
1195
  }
1062
1196
  await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
1063
1197
  // rev-list, not status.behind, which needs an upstream tracking branch
@@ -1088,6 +1222,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1088
1222
  // whose cause stands stays: remote.git still holds a commit this clone
1089
1223
  // lacks, and the next submit is refused for it.
1090
1224
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
1225
+ if (currentMeta !== null)
1226
+ await recordLatestChangesWaiting(ctx, branchPath, currentMeta);
1091
1227
  if (currentMeta?.branch.rebaseFailure === undefined) {
1092
1228
  return { kind: 'none' };
1093
1229
  }
@@ -1110,13 +1246,17 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1110
1246
  const head = (await branchGit.revparse(['HEAD'])).trim();
1111
1247
  // Commits on top of what this branch published, as "Get latest changes" makes from saved work
1112
1248
  // on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
1113
- // published commit under them, which reads as a push this clone never saw. A merge publishes
1114
- // them as a fast-forward. A same-named ref at a commit the base holds is no work of this branch.
1249
+ // published commit under them, which reads as a push this clone never saw. A merge keeps
1250
+ // them, unpublished until the submit pushes it (publish-decision.ts). A same-named ref at a
1251
+ // commit the base holds is no work of this branch.
1115
1252
  const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
1253
+ // With no GitHub, a commit of the branch's in remote.git is published however it got there.
1116
1254
  const aheadOfPublished = published !== null &&
1117
- published !== head &&
1118
1255
  metaFile?.branch.name === branchRef &&
1119
- isPublished(metaFile) &&
1256
+ (local ||
1257
+ (published !== head &&
1258
+ // A rewrite's tip is this branch's own publish, on GitHub or not.
1259
+ (isPublished(metaFile) || published === metaFile.branch.historyRewrittenTo))) &&
1120
1260
  (await contains(branchGit, head, published)) &&
1121
1261
  !(await contains(branchGit, fetchedBaseTip, published));
1122
1262
  /**
@@ -1149,7 +1289,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1149
1289
  return { kind: 'skippedLocked' };
1150
1290
  }
1151
1291
  probe.keptEntries = outcome.keptEntries;
1152
- await clearRebaseFailure(ctx, branchPath, branchRef);
1292
+ await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
1153
1293
  return { kind: 'rebased' };
1154
1294
  };
1155
1295
  if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
@@ -1175,6 +1315,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1175
1315
  const publishedSha = published;
1176
1316
  await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
1177
1317
  const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
1318
+ // The tip the rebase made, read before anything else awaits: past a lost lock a commit can
1319
+ // land on it, and only this tip may be published as the rewrite.
1320
+ const rebasedHead = (await branchGit.revparse(['HEAD'])).trim();
1178
1321
  // Outside the round loop's try/catch, so a throwing test hook can
1179
1322
  // never be misread as a rebase error.
1180
1323
  if (completed)
@@ -1248,6 +1391,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1248
1391
  branch: {
1249
1392
  conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
1250
1393
  rebaseFailure: undefined,
1394
+ latestChangesWaiting: undefined,
1251
1395
  },
1252
1396
  }
1253
1397
  : null);
@@ -1258,7 +1402,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1258
1402
  branchRef,
1259
1403
  publishedSha,
1260
1404
  preRebaseHead,
1261
- rebasedHead: (await branchGit.revparse(['HEAD'])).trim(),
1405
+ rebasedHead,
1262
1406
  gatewayPolicy,
1263
1407
  });
1264
1408
  return { kind: 'rebased' };
@@ -16,6 +16,7 @@ import { writeWorkerStatus } from '../task-queue/worker-status.js';
16
16
  import { SYNC_PR_COMMENTS_ACTION, refreshCommentsBlock, renderPrCommentsBody } from '../pr-comments.js';
17
17
  import { SYNC_BRANCH_ACTION } from '../base-update.js';
18
18
  import { COMMIT_ID, REWRITTEN_PUSH_REFUSAL, SEEN_COMMIT_GONE_PUSH_REFUSAL, enqueueReviewBlockSync, } from './github-commits.js';
19
+ import { getHttpStatus, isRateLimitSignal403 } from './github-errors.js';
19
20
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
20
21
  import { assertSharedRepoConfig } from './shared-repo-git.js';
21
22
  /**
@@ -85,45 +86,6 @@ export function isPermanentTaskFailure(err) {
85
86
  return false;
86
87
  return status >= 400 && status < 500;
87
88
  }
88
- /** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
89
- function getHttpStatus(err) {
90
- if (err instanceof Error && 'status' in err) {
91
- const status = err.status;
92
- if (typeof status === 'number')
93
- return status;
94
- }
95
- return null;
96
- }
97
- /** Narrow an unknown value to a response-headers record, if present (Octokit lowercases header names). */
98
- function getResponseHeaders(err) {
99
- if (typeof err !== 'object' || err === null || !('response' in err))
100
- return null;
101
- const response = err.response;
102
- if (typeof response !== 'object' || response === null || !('headers' in response))
103
- return null;
104
- const headers = response.headers;
105
- if (typeof headers !== 'object' || headers === null)
106
- return null;
107
- return headers;
108
- }
109
- /**
110
- * Whether a 403 is a GitHub rate-limit response rather than a plain permission
111
- * denial. GitHub signals rate limiting on 403s three ways: the primary limit
112
- * zeroes `x-ratelimit-remaining`, secondary limits often carry `retry-after`,
113
- * and both produce a message containing "rate limit".
114
- */
115
- function isRateLimitSignal403(err) {
116
- const headers = getResponseHeaders(err);
117
- if (headers) {
118
- if (headers['x-ratelimit-remaining'] === '0')
119
- return true;
120
- if (typeof headers['retry-after'] === 'string' && headers['retry-after'].length > 0)
121
- return true;
122
- }
123
- if (err instanceof Error && /rate limit/i.test(err.message))
124
- return true;
125
- return false;
126
- }
127
89
  // Payload validation helpers — fail fast with clear errors instead of silent `as` casts
128
90
  /**
129
91
  * Whether `branch` carries the reserved settings-branch prefix, which routes a submit to a push
@@ -602,6 +564,7 @@ export async function updateBranchMetadata(ctx, task, result) {
602
564
  return;
603
565
  }
604
566
  let found = false;
567
+ let newPullRequest = false;
605
568
  // Bookkeeping: the save that queued this already said when the branch last changed.
606
569
  await meta.update((existing) => {
607
570
  if (existing?.branch.name !== branch)
@@ -626,6 +589,7 @@ export async function updateBranchMetadata(ctx, task, result) {
626
589
  if (result.prUrl && !archived)
627
590
  updates.pullRequestUrl = result.prUrl;
628
591
  if (result.prNumber && !archived) {
592
+ newPullRequest = existing.branch.pullRequestNumber !== result.prNumber;
629
593
  updates.pullRequestNumber = result.prNumber;
630
594
  // As soon as the PR exists the field reads 'open', rather than staying
631
595
  // absent until the next poll cycle observes it. Exception: the git-sync
@@ -641,6 +605,9 @@ export async function updateBranchMetadata(ctx, task, result) {
641
605
  }, { keepUpdatedAt: true });
642
606
  if (!found)
643
607
  noMetadataToRecord(branch, `${task.action} outcome`);
608
+ // The sync its push woke may have run before the number was recorded, and skipped the branch.
609
+ if (newPullRequest)
610
+ ctx.requestBranchSync(branch);
644
611
  }
645
612
  catch (err) {
646
613
  workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
@@ -848,6 +815,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
848
815
  // has moved past the rewritten commit and the marker is spent.
849
816
  await clearHistoryRewrittenMarker(ctx, branchPath, branch);
850
817
  await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
818
+ wakeForConflictsStatus(ctx, branch, outcome);
851
819
  logPushOutcome(ctx, branch, outcome, 'GitHub had already moved past the rewritten commit');
852
820
  return;
853
821
  }
@@ -859,8 +827,17 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
859
827
  await clearHistoryRewrittenMarker(ctx, branchPath, branch);
860
828
  }
861
829
  await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
830
+ wakeForConflictsStatus(ctx, branch, outcome);
862
831
  logPushOutcome(ctx, branch, outcome);
863
832
  }
833
+ /**
834
+ * A pull request's new head has no conflicts status until a git sync sets one
835
+ * (worker/conflicts-status.ts), and a required check waits on it: wake the sync for the branch.
836
+ */
837
+ function wakeForConflictsStatus(ctx, branch, outcome) {
838
+ if (outcome.moved && !isSettingsBranch(branch))
839
+ ctx.requestBranchSync(branch);
840
+ }
864
841
  /** An unmoved ref is routine (a task re-run after a crash), so it is logged at debug level. */
865
842
  function logPushOutcome(ctx, branch, outcome, note) {
866
843
  const suffix = note === undefined ? '' : `; ${note}`;
@@ -110,6 +110,12 @@ export interface WorkerContext {
110
110
  * restarted worker warns afresh.
111
111
  */
112
112
  warnedDivergences(): Map<string, string>;
113
+ /**
114
+ * The conflicts status this worker last set on each branch's pull request head, by branch name,
115
+ * and the tip each `not-head:` key last woke a sync for (worker/conflicts-status.ts). Held per
116
+ * instance, so a restarted worker sets each once again.
117
+ */
118
+ conflictsStatusesSet(): Map<string, string>;
113
119
  /**
114
120
  * Run the git sync early, coalesced: once it has rested `EARLY_RUN_MIN_REST_MS` (cms-worker.ts),
115
121
  * or its own interval if shorter, counting from when the last cycle ended, so a request during a
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.12.1",
4
+ "version": "0.13.0",
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": {