canopycms 0.9.0 → 0.11.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 (68) hide show
  1. package/CHANGELOG.md +74 -48
  2. package/dist/api/admin.d.ts +11 -0
  3. package/dist/api/admin.js +2 -0
  4. package/dist/api/archived-branches.d.ts +70 -0
  5. package/dist/api/archived-branches.js +146 -0
  6. package/dist/api/branch-merge.js +16 -1
  7. package/dist/api/branch-status.d.ts +11 -0
  8. package/dist/api/branch-status.js +2 -0
  9. package/dist/api/branch-withdraw.js +1 -1
  10. package/dist/api/branch.js +46 -108
  11. package/dist/api/client.d.ts +25 -21
  12. package/dist/api/client.js +46 -38
  13. package/dist/branch-archive.d.ts +19 -0
  14. package/dist/branch-archive.js +27 -0
  15. package/dist/branch-delete.d.ts +45 -0
  16. package/dist/branch-delete.js +97 -0
  17. package/dist/branch-metadata-file.d.ts +3 -0
  18. package/dist/branch-metadata-file.js +1 -0
  19. package/dist/branch-metadata.d.ts +6 -1
  20. package/dist/branch-metadata.js +15 -2
  21. package/dist/cli/cli.js +2 -0
  22. package/dist/cli/generate-ai-content.js +1 -0
  23. package/dist/editor/BranchManager.d.ts +20 -0
  24. package/dist/editor/BranchManager.js +92 -26
  25. package/dist/editor/Editor.js +49 -25
  26. package/dist/editor/PreviewFrame.d.ts +8 -1
  27. package/dist/editor/PreviewFrame.js +15 -2
  28. package/dist/editor/PreviewPane.d.ts +7 -2
  29. package/dist/editor/PreviewPane.js +6 -5
  30. package/dist/editor/PreviewToolbarTools.d.ts +6 -0
  31. package/dist/editor/PreviewToolbarTools.js +6 -1
  32. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  33. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  34. package/dist/editor/admin/useSystemHealth.js +59 -0
  35. package/dist/editor/branch-status.js +9 -0
  36. package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
  37. package/dist/editor/comments/CommentThreadsPopover.js +9 -1
  38. package/dist/editor/comments/FieldWrapper.js +3 -1
  39. package/dist/editor/copy.d.ts +3 -0
  40. package/dist/editor/copy.js +3 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  42. package/dist/editor/hooks/useBranchManager.js +55 -4
  43. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  44. package/dist/editor/hooks/useBranchesData.js +3 -1
  45. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  46. package/dist/editor/hooks/useEditorLayout.js +5 -0
  47. package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
  48. package/dist/editor/hooks/usePreviewFollow.js +54 -0
  49. package/dist/editor/preview-bridge.d.ts +25 -0
  50. package/dist/editor/preview-bridge.js +175 -0
  51. package/dist/preview.d.ts +2 -2
  52. package/dist/preview.js +1 -1
  53. package/dist/types.d.ts +33 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/worker/archived-sweep.d.ts +23 -0
  57. package/dist/worker/archived-sweep.js +137 -0
  58. package/dist/worker/cms-worker.d.ts +6 -0
  59. package/dist/worker/cms-worker.js +7 -0
  60. package/dist/worker/git-sync.d.ts +4 -4
  61. package/dist/worker/git-sync.js +3 -0
  62. package/dist/worker/github-commits.d.ts +2 -2
  63. package/dist/worker/github-commits.js +5 -2
  64. package/dist/worker/github-gateway-wire.d.ts +2 -2
  65. package/dist/worker/rebase.js +6 -2
  66. package/dist/worker/task-runner.js +7 -4
  67. package/dist/worker/worker-context.d.ts +2 -0
  68. package/package.json +1 -1
package/dist/types.d.ts CHANGED
@@ -12,6 +12,22 @@ export type CanopyGroupId = string;
12
12
  * semantics (worker rebase skip list, a set/unset endpoint, UI).
13
13
  */
14
14
  export type BranchStatus = 'editing' | 'submitted' | 'approved' | 'archived';
15
+ /** One cycle of the worker's retention sweep (worker/archived-sweep.ts). */
16
+ export interface ArchivedSweepReport {
17
+ /** Full names of the branches deleted. */
18
+ deleted: string[];
19
+ /** Due, but another process held the branch's provisioning lock; retried next cycle. */
20
+ skippedLocked: string[];
21
+ /** [REDACT] Served to the admin panel, so `error` is sanitized. */
22
+ failed: {
23
+ branch: string;
24
+ error: string;
25
+ }[];
26
+ /** Due, beyond this cycle's cap; the next cycle takes the oldest of them. */
27
+ deferred: number;
28
+ /** Deleted, with something left behind, sanitized as `failed` is ([REDACT]). */
29
+ warnings: string[];
30
+ }
15
31
  export type SyncStatus = 'synced' | 'pending-sync' | 'sync-failed';
16
32
  export type ConflictStatus = 'clean' | 'conflicts-detected';
17
33
  export type PullRequestState = 'open' | 'closed' | 'merged';
@@ -57,6 +73,14 @@ export type BranchHold = PullRequestHold | {
57
73
  /** Admins see what to do in System health. */
58
74
  | {
59
75
  kind: 'unpublished-at-merge';
76
+ }
77
+ /**
78
+ * An archived branch rewritten on GitHub, which may hold commits GitHub dropped. `commit`, the
79
+ * one GitHub no longer contains, is for System health; the API leaves it out.
80
+ */
81
+ | {
82
+ kind: 'rewritten-at-merge';
83
+ commit?: string;
60
84
  };
61
85
  export interface BranchMetadata {
62
86
  name: string;
@@ -106,6 +130,11 @@ export interface BranchMetadata {
106
130
  * (worker auto-poll or markAsMerged). Absent for branches archived any other way.
107
131
  */
108
132
  mergedAt?: string;
133
+ /**
134
+ * When the branch was archived, merged or not: the clock retention counts from. A branch
135
+ * archived before this field existed has only `mergedAt` (branch-archive.ts).
136
+ */
137
+ archivedAt?: string;
109
138
  /**
110
139
  * Set by the worker when a rebase cycle fails for this branch; cleared on
111
140
  * the next successful cycle (and on submit). firstAt survives repeated
@@ -292,6 +321,8 @@ export interface WorkerStatusReport {
292
321
  updatedAt: string;
293
322
  lastTaskCycleAt?: string;
294
323
  lastGitSyncAt?: string;
324
+ /** The deployment's `archivedBranchRetentionDays`; absent when retention is off. */
325
+ archivedBranchRetentionDays?: number;
295
326
  lastGitSyncError?: {
296
327
  message: string;
297
328
  at: string;
@@ -314,6 +345,8 @@ export interface WorkerStatusReport {
314
345
  }[];
315
346
  /** Optional for the same reason as `tracked` below. */
316
347
  baseRefresh?: BaseRefreshReport;
348
+ /** The retention sweep's work this cycle; absent when it found nothing to do. */
349
+ archivedSweep?: ArchivedSweepReport;
317
350
  /**
318
351
  * Outcome of reconciling remote.git's `refs/heads/*` against GitHub's fetched
319
352
  * tips, never moving a head the worker finds editor work in (worker/git-sync.ts's
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.9.0";
1
+ export declare const CANOPYCMS_VERSION = "0.11.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.9.0';
3
+ export const CANOPYCMS_VERSION = '0.11.0';
@@ -0,0 +1,23 @@
1
+ import type { ArchivedSweepReport } from '../types.js';
2
+ import type { WorkerContext } from './worker-context.js';
3
+ export type ArchivedSweepContext = Pick<WorkerContext, 'archivedBranchRetentionDays' | 'contentBranchesPath' | 'ensureSettingsBranch' | 'isDraining' | 'sanitizedBaseBranch'>;
4
+ /**
5
+ * The most branches one cycle deletes, oldest first: turning retention on over a long backlog must
6
+ * not stall the sync loop. The rest wait for the next cycle.
7
+ * @internal Exported for tests.
8
+ */
9
+ export declare const MAX_ARCHIVED_SWEEP_PER_CYCLE = 25;
10
+ /**
11
+ * Delete merged, archived branches past `archivedBranchRetentionDays`, oldest first, through the
12
+ * one delete path (branch-delete.ts). Off unless the deployment set that.
13
+ *
14
+ * Each branch is held by its provisioning lock, zero-retry as everywhere in the sync loop, and the
15
+ * selection is decided again under the lock the delete takes, from a fresh read.
16
+ *
17
+ * Takes the editor's copy only: `remoteCopies` in branch-delete.ts says why, and reconcile prunes
18
+ * a head no workspace holds once GitHub drops it (stale-mirror.ts).
19
+ *
20
+ * Never throws, and one branch's failure costs only that branch, which the next cycle retries. A
21
+ * kill past the rename leaves a `.deleting-*` directory `sweepProvisioningLeftovers` clears.
22
+ */
23
+ export declare function sweepArchivedBranches(ctx: ArchivedSweepContext, now?: number): Promise<ArchivedSweepReport | undefined>;
@@ -0,0 +1,137 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { archivedCutoff, archivedTime, deletableByAge } from '../branch-archive.js';
4
+ import { deleteBranchWorkspace } from '../branch-delete.js';
5
+ import { BranchMetadataCorruptError, BranchMetadataFileManager } from '../branch-metadata.js';
6
+ import { BranchRegistry } from '../branch-registry.js';
7
+ import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
8
+ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
9
+ import { escapeForLog } from '../utils/escape-text.js';
10
+ import { workerLog, workerLogError, workerLogWarn } from './log.js';
11
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
12
+ /**
13
+ * The most branches one cycle deletes, oldest first: turning retention on over a long backlog must
14
+ * not stall the sync loop. The rest wait for the next cycle.
15
+ * @internal Exported for tests.
16
+ */
17
+ export const MAX_ARCHIVED_SWEEP_PER_CYCLE = 25;
18
+ /** Branches past the cutoff, oldest first. Reads disk: a stale registry must not choose them. */
19
+ async function listDueBranches(ctx, cutoff) {
20
+ const settingsBranch = ctx.ensureSettingsBranch();
21
+ const due = [];
22
+ const entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
23
+ for (const entry of entries) {
24
+ const dirName = entry.name;
25
+ if (!entry.isDirectory() || dirName.startsWith('.'))
26
+ continue;
27
+ if (dirName === ctx.sanitizedBaseBranch || isSettingsBranchName(dirName, settingsBranch)) {
28
+ continue;
29
+ }
30
+ let branch;
31
+ try {
32
+ const metaFile = await BranchMetadataFileManager.loadOnly(path.join(ctx.contentBranchesPath, dirName));
33
+ branch = metaFile?.branch;
34
+ }
35
+ catch (err) {
36
+ // One unreadable directory costs only itself. System health lists a corrupt branch.json.
37
+ if (!(err instanceof BranchMetadataCorruptError)) {
38
+ workerLogWarn(`Skipping ${dirName} in the archived sweep: ${getErrorMessage(err)}`);
39
+ }
40
+ }
41
+ if (branch !== undefined &&
42
+ sanitizeBranchName(branch.name) === dirName &&
43
+ (branch.baseBranch === undefined || sanitizeBranchName(branch.baseBranch) !== dirName) &&
44
+ deletableByAge(branch, cutoff)) {
45
+ due.push({ dirName, branch });
46
+ }
47
+ }
48
+ return due.sort((a, b) => (archivedTime(a.branch) ?? 0) - (archivedTime(b.branch) ?? 0));
49
+ }
50
+ /**
51
+ * Delete merged, archived branches past `archivedBranchRetentionDays`, oldest first, through the
52
+ * one delete path (branch-delete.ts). Off unless the deployment set that.
53
+ *
54
+ * Each branch is held by its provisioning lock, zero-retry as everywhere in the sync loop, and the
55
+ * selection is decided again under the lock the delete takes, from a fresh read.
56
+ *
57
+ * Takes the editor's copy only: `remoteCopies` in branch-delete.ts says why, and reconcile prunes
58
+ * a head no workspace holds once GitHub drops it (stale-mirror.ts).
59
+ *
60
+ * Never throws, and one branch's failure costs only that branch, which the next cycle retries. A
61
+ * kill past the rename leaves a `.deleting-*` directory `sweepProvisioningLeftovers` clears.
62
+ */
63
+ export async function sweepArchivedBranches(ctx, now = Date.now()) {
64
+ if (ctx.archivedBranchRetentionDays === undefined)
65
+ return undefined;
66
+ const cutoff = archivedCutoff(ctx.archivedBranchRetentionDays, now);
67
+ let due;
68
+ try {
69
+ due = await listDueBranches(ctx, cutoff);
70
+ }
71
+ catch (err) {
72
+ if (!isNotFoundError(err)) {
73
+ workerLogError('Could not list archived branches to sweep:', getErrorMessage(err));
74
+ }
75
+ return undefined;
76
+ }
77
+ if (due.length === 0)
78
+ return undefined;
79
+ const report = {
80
+ deleted: [],
81
+ skippedLocked: [],
82
+ failed: [],
83
+ deferred: Math.max(0, due.length - MAX_ARCHIVED_SWEEP_PER_CYCLE),
84
+ warnings: [],
85
+ };
86
+ const registry = new BranchRegistry(ctx.contentBranchesPath);
87
+ for (const { dirName, branch } of due.slice(0, MAX_ARCHIVED_SWEEP_PER_CYCLE)) {
88
+ if (ctx.isDraining())
89
+ break;
90
+ const { name } = branch;
91
+ try {
92
+ const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, dirName);
93
+ if (hold.kind === 'locked') {
94
+ report.skippedLocked.push(name);
95
+ continue;
96
+ }
97
+ if (hold.kind === 'not-provisioned')
98
+ continue;
99
+ try {
100
+ if (hold.isCompromised()) {
101
+ report.skippedLocked.push(name);
102
+ continue;
103
+ }
104
+ const outcome = await deleteBranchWorkspace({
105
+ context: {
106
+ baseRoot: ctx.contentBranchesPath,
107
+ branchRoot: path.join(ctx.contentBranchesPath, dirName),
108
+ branch,
109
+ },
110
+ registry,
111
+ stillWanted: (current) => current.name === name && deletableByAge(current, cutoff),
112
+ });
113
+ if (outcome.kind === 'deleted') {
114
+ report.deleted.push(name);
115
+ workerLog(` Deleted archived branch ${escapeForLog(name)}: past the retention period`);
116
+ if (outcome.cleanupWarning)
117
+ report.warnings.push(`${name}: ${outcome.cleanupWarning}`);
118
+ }
119
+ else if (outcome.kind === 'busy') {
120
+ report.failed.push({ branch: name, error: sanitizeErrorMessage(outcome.error) });
121
+ }
122
+ }
123
+ finally {
124
+ await releaseProvisionedWorkspace(hold.release, dirName);
125
+ }
126
+ }
127
+ catch (err) {
128
+ report.failed.push({ branch: name, error: sanitizeErrorMessage(getErrorMessage(err)) });
129
+ }
130
+ }
131
+ for (const { branch, error } of report.failed) {
132
+ workerLogWarn(` Could not delete archived branch ${escapeForLog(branch)}: ${error}`);
133
+ }
134
+ for (const warning of report.warnings)
135
+ workerLogWarn(` ${escapeForLog(warning)}`);
136
+ return report;
137
+ }
@@ -54,6 +54,11 @@ export interface CmsWorkerConfig extends GitHubAuthConfig {
54
54
  gitSyncInterval?: number;
55
55
  /** Auth cache refresh interval in ms (default: 15 * 60 * 1000) */
56
56
  authCacheRefreshInterval?: number;
57
+ /**
58
+ * Delete a merged branch's workspace, metadata and comments this many days after it was archived
59
+ * (worker/archived-sweep.ts). A positive integer; unset keeps archived branches.
60
+ */
61
+ archivedBranchRetentionDays?: number;
57
62
  /** Base branch name (default: detected at start(); see `resolveBaseBranch`) */
58
63
  baseBranch?: string;
59
64
  /**
@@ -190,6 +195,7 @@ export declare class CmsWorker {
190
195
  private maxTasksPerCycle;
191
196
  private taskTimeoutMs;
192
197
  private gitSyncIntervalMs;
198
+ private readonly archivedBranchRetentionDays;
193
199
  private maxRetries;
194
200
  private lockFilePath;
195
201
  private lockStaleMs;
@@ -261,6 +261,11 @@ export class CmsWorker {
261
261
  this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
262
262
  this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
263
263
  this.gitSyncIntervalMs = config.gitSyncInterval ?? 5 * 60_000;
264
+ const retentionDays = config.archivedBranchRetentionDays;
265
+ if (retentionDays !== undefined && !(Number.isInteger(retentionDays) && retentionDays > 0)) {
266
+ throw new Error(`archivedBranchRetentionDays must be a positive whole number of days, not ${retentionDays}`);
267
+ }
268
+ this.archivedBranchRetentionDays = retentionDays;
264
269
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
265
270
  this.lockFilePath = path.join(config.workspacePath, '.tasks', '.worker-lock');
266
271
  this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
@@ -282,6 +287,7 @@ export class CmsWorker {
282
287
  workerVersion: CANOPYCMS_VERSION,
283
288
  startedAt: now,
284
289
  updatedAt: now,
290
+ archivedBranchRetentionDays: this.archivedBranchRetentionDays,
285
291
  };
286
292
  }
287
293
  return this.statusReport;
@@ -334,6 +340,7 @@ export class CmsWorker {
334
340
  contentBranchesPath: this.contentBranchesPath,
335
341
  contentRoot: this.contentRoot,
336
342
  schemaHoldMaxMs: this.schemaHoldMaxMs,
343
+ archivedBranchRetentionDays: this.archivedBranchRetentionDays,
337
344
  taskTimeoutMs: this.taskTimeoutMs,
338
345
  gitSyncIntervalMs: this.gitSyncIntervalMs,
339
346
  maxTasksPerCycle: this.maxTasksPerCycle,
@@ -16,8 +16,8 @@ import type { WorkerContext } from './worker-context.js';
16
16
  * says the serving editor lacks a schema the incoming content names), push this
17
17
  * deployment's own settings
18
18
  * branch, fast-forward the base branch's workspace, ask the gateway for its branch policy,
19
- * rebase every branch that is behind it (rebase.ts), then sweep old tasks and expired trashed
20
- * branch directories.
19
+ * rebase every branch that is behind it (rebase.ts), then sweep old tasks, expired trashed
20
+ * branch directories and, where retention is on, expired archived branches.
21
21
  *
22
22
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
23
23
  * follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
@@ -26,13 +26,13 @@ import type { WorkerContext } from './worker-context.js';
26
26
  * reorder them and every branch rebases onto the PREVIOUS cycle's
27
27
  * base tip -- not corrupting, but silently a cycle behind.
28
28
  * (`pushSettingsBranches` consuming the fetch's tips is a DATA dependency its
29
- * signature already enforces; `refreshBaseBranchWorkspace` and the two sweeps
29
+ * signature already enforces; `refreshBaseBranchWorkspace` and the sweeps
30
30
  * are order-independent.)
31
31
  *
32
32
  * The whole cycle is wrapped so both outcomes record a worker-status.json
33
33
  * snapshot, since an operator's only view of this loop is the admin panel.
34
34
  */
35
- export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
35
+ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
36
36
  /**
37
37
  * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
38
38
  * remote.git to GitHub. Non-fatal. Skipped when `trackedTips`, GitHub's tips
@@ -13,6 +13,7 @@ import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.
13
13
  import { escapeForLog } from '../utils/escape-text.js';
14
14
  import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
15
15
  import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
16
+ import { sweepArchivedBranches } from './archived-sweep.js';
16
17
  import { hasPendingHistoryRewrite, recordSeenOnGitHub } from './history-rewrite.js';
17
18
  import { runRebaseCycle } from './rebase.js';
18
19
  import { describeMove } from './github-gateway.js';
@@ -545,6 +546,7 @@ export async function syncGit(ctx) {
545
546
  if (trashRemoved > 0) {
546
547
  workerLog(`Removed ${trashRemoved} expired trashed branch dir(s)`);
547
548
  }
549
+ const archivedSweep = await sweepArchivedBranches(ctx);
548
550
  const report = ctx.ensureStatusReport();
549
551
  report.lastGitSyncAt = new Date().toISOString();
550
552
  delete report.lastGitSyncError;
@@ -555,6 +557,7 @@ export async function syncGit(ctx) {
555
557
  report.lastGitSync = {
556
558
  durationMs: Date.now() - cycleStartedAt,
557
559
  rebased: rebaseSummary.rebased,
560
+ ...(archivedSweep && { archivedSweep }),
558
561
  skippedDirty: baseRefresh.outcome === 'skipped-dirty'
559
562
  ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
560
563
  : rebaseSummary.skippedDirty,
@@ -9,8 +9,8 @@ import type { WorkerContext } from './worker-context.js';
9
9
  * `remote.git` to them, but branch clones never fetch their own branch, so without this step the
10
10
  * clone's next rebase or commit diverges from `remote.git` and every submit after that is refused.
11
11
  *
12
- * Runs once per cycle for every branch this deployment has published, in every status but
13
- * archived, under the provisioning and content-write locks the caller holds, ahead of the rebase.
12
+ * Brings them in once per cycle for every branch this deployment has published, in every status
13
+ * but archived, under the provisioning and content-write locks the caller holds, ahead of the rebase.
14
14
  * It never forces anything: a clone behind GitHub fast-forwards, and a clone with commits GitHub
15
15
  * lacks gets a real merge (branch-merge.ts), which keeps every commit on both sides.
16
16
  *
@@ -41,13 +41,16 @@ export async function bringInGitHubCommits(ctx, args) {
41
41
  const { git, branchRef, metaFile, dirty, gatewayPolicy } = args;
42
42
  const { branch } = metaFile;
43
43
  // A branch.json naming another ref is that branch's, which shares this directory's name.
44
- if (branch.name !== branchRef || !isPublished(metaFile))
44
+ if (branch.name !== branchRef)
45
45
  return { kind: 'in-step' };
46
46
  const gitHubTip = args.gitHub?.tips.get(branchRef);
47
- // No GitHub branch leaves nothing a push could restore.
47
+ // No GitHub branch leaves nothing a push could restore. Ahead of `isPublished`: withdrawing drops
48
+ // a closed PR's number, which can be all that marked the branch published.
48
49
  if (args.gitHub !== undefined && gitHubTip === undefined && branch.rewrittenOnGitHub) {
49
50
  await clearRewritten(ctx, args);
50
51
  }
52
+ if (!isPublished(metaFile))
53
+ return { kind: 'in-step' };
51
54
  const published = await readPublishedSha(ctx, branchRef);
52
55
  const tip = gitHubTip ?? published;
53
56
  if (tip === null)
@@ -362,13 +362,13 @@ export declare const fetchPoll: z.ZodDiscriminatedUnion<"state", [z.ZodObject<{
362
362
  bundleId: z.ZodNullable<z.ZodString>;
363
363
  }, "strict", z.ZodTypeAny, {
364
364
  state: "done";
365
- progress: number;
366
365
  refs: [string, string][];
366
+ progress: number;
367
367
  bundleId: string | null;
368
368
  }, {
369
369
  state: "done";
370
- progress: number;
371
370
  refs: [string, string][];
371
+ progress: number;
372
372
  bundleId: string | null;
373
373
  }>]>;
374
374
  export declare const pushAnswer: z.ZodUnion<[z.ZodObject<{
@@ -525,7 +525,11 @@ async function bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaF
525
525
  if (gitHubTip === undefined) {
526
526
  if (gitHub === undefined)
527
527
  return { kind: 'none' };
528
- const { rebaseFailure, rewrittenOnGitHub } = metaFile.branch;
528
+ const { rebaseFailure, rewrittenOnGitHub, pullRequestNumber } = metaFile.branch;
529
+ // A merge can delete the head branch, so with a PR recorded a rewrite stays for the
530
+ // merge poll to find: it is what holds the branch from deletion by age (branch-archive.ts).
531
+ if (rewrittenOnGitHub !== undefined && pullRequestNumber !== undefined)
532
+ return { kind: 'none' };
529
533
  if (rebaseFailure !== undefined || rewrittenOnGitHub !== undefined) {
530
534
  await clearRebaseFailure(ctx, branchPath, branchRef);
531
535
  }
@@ -747,7 +751,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
747
751
  // - submitted/approved: in review, so never rewrite history under an active
748
752
  // PR -- but do poll GitHub for the PR's resolution, since nothing else
749
753
  // tells the worker a merge/close happened;
750
- // - archived: already merged, with no PR left to poll.
754
+ // - archived: finished, with no PR left to poll.
751
755
  if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
752
756
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
753
757
  const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
@@ -1,5 +1,6 @@
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
+ import { filePathExists } from '../utils/fs.js';
3
4
  import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
4
5
  import { BranchMetadataCorruptError, BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
5
6
  import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
@@ -507,12 +508,14 @@ async function isSupersededSubmit(ctx, task, branch) {
507
508
  if (typeof submittedAt !== 'string')
508
509
  return false;
509
510
  const live = await BranchMetadataFileManager.loadOnly(ctx.branchWorkspacePath(branch));
511
+ const firstRun = !task.retryCount && requeuedFrom === undefined;
512
+ // A retry with no workspace at all: its branch was deleted since. A first run proceeds, since a
513
+ // quarantine's rename can hide a live workspace for a moment.
510
514
  if (live === null)
511
- return false;
515
+ return !firstRun && !(await filePathExists(ctx.branchWorkspacePath(branch)));
512
516
  const { status, submittedAt: recorded } = live.branch;
513
517
  if (status === 'archived')
514
518
  return true;
515
- const firstRun = !task.retryCount && requeuedFrom === undefined;
516
519
  if (firstRun && (recorded === undefined || recorded < submittedAt))
517
520
  return false;
518
521
  return (status !== 'submitted' && status !== 'approved') || recorded !== submittedAt;
@@ -606,7 +609,7 @@ export async function updateBranchMetadata(ctx, task, result) {
606
609
  // A pull request opened as the branch merged is not this branch's, whose record is the merge.
607
610
  const archived = existing.branch.status === 'archived';
608
611
  if (archived && result.prNumber) {
609
- workerLogWarn(`Not recording pull request #${result.prNumber} on merged ${branch}`);
612
+ workerLogWarn(`Not recording pull request #${result.prNumber} on archived ${branch}`);
610
613
  }
611
614
  if (result.prUrl && !archived)
612
615
  updates.pullRequestUrl = result.prUrl;
@@ -683,7 +686,7 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
683
686
  // merge already set stands, for an admin to dismiss.
684
687
  if (options.unlock || task.action !== SUBMIT_ACTION)
685
688
  return { branch: failed };
686
- // Merged while this ran: nothing is left to finish it, as buildMergedBranchUpdate records.
689
+ // Archived while this ran: nothing is left to finish it, as buildMergedBranchUpdate records.
687
690
  if (existing.branch.status === 'archived') {
688
691
  return { branch: { ...failed, unpublishedAtMerge: true } };
689
692
  }
@@ -48,6 +48,8 @@ export interface WorkerContext {
48
48
  readonly contentRoot: string;
49
49
  /** Longest the schema gate holds the base branch before advancing anyway (worker/schema-gate.ts). */
50
50
  readonly schemaHoldMaxMs: number;
51
+ /** Days after archiving that the sync loop deletes a merged branch; undefined leaves them all. */
52
+ readonly archivedBranchRetentionDays: number | undefined;
51
53
  /** Per-task timeout in ms; also simple-git's inactivity block timeout. */
52
54
  readonly taskTimeoutMs: number;
53
55
  /** How long the sync loop rests between cycles, in ms. */
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.9.0",
4
+ "version": "0.11.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": {