canopycms 0.9.0 → 0.10.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.
- package/CHANGELOG.md +60 -48
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +2 -0
- package/dist/cli/generate-ai-content.js +1 -0
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +5 -1
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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<{
|
package/dist/worker/rebase.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
4
|
+
"version": "0.10.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": {
|