canopycms 0.0.59 → 0.0.61-int.74
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/dist/api/__test__/mock-client.d.ts +29 -0
- package/dist/api/__test__/mock-client.d.ts.map +1 -1
- package/dist/api/__test__/mock-client.js +55 -4
- package/dist/api/__test__/mock-client.js.map +1 -1
- package/dist/api/admin-branch-health.d.ts +65 -0
- package/dist/api/admin-branch-health.d.ts.map +1 -0
- package/dist/api/admin-branch-health.js +448 -0
- package/dist/api/admin-branch-health.js.map +1 -0
- package/dist/api/admin.d.ts +136 -0
- package/dist/api/admin.d.ts.map +1 -0
- package/dist/api/admin.js +338 -0
- package/dist/api/admin.js.map +1 -0
- package/dist/api/assets.d.ts.map +1 -1
- package/dist/api/assets.js +4 -0
- package/dist/api/assets.js.map +1 -1
- package/dist/api/branch-merge.d.ts +6 -0
- package/dist/api/branch-merge.d.ts.map +1 -1
- package/dist/api/branch-merge.js +15 -9
- package/dist/api/branch-merge.js.map +1 -1
- package/dist/api/branch-status.d.ts.map +1 -1
- package/dist/api/branch-status.js +15 -5
- package/dist/api/branch-status.js.map +1 -1
- package/dist/api/branch-withdraw.d.ts +6 -0
- package/dist/api/branch-withdraw.d.ts.map +1 -1
- package/dist/api/branch-withdraw.js +34 -7
- package/dist/api/branch-withdraw.js.map +1 -1
- package/dist/api/branch.d.ts +11 -1
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +70 -4
- package/dist/api/branch.js.map +1 -1
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +47 -0
- package/dist/api/client.js.map +1 -1
- package/dist/api/comments.d.ts.map +1 -1
- package/dist/api/comments.js +4 -0
- package/dist/api/comments.js.map +1 -1
- package/dist/api/content.d.ts.map +1 -1
- package/dist/api/content.js +4 -4
- package/dist/api/content.js.map +1 -1
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +23 -4
- package/dist/api/entries.js.map +1 -1
- package/dist/api/github-sync.d.ts.map +1 -1
- package/dist/api/github-sync.js +14 -3
- package/dist/api/github-sync.js.map +1 -1
- package/dist/api/groups.d.ts +3 -0
- package/dist/api/groups.d.ts.map +1 -1
- package/dist/api/groups.js +89 -68
- package/dist/api/groups.js.map +1 -1
- package/dist/api/guards.d.ts +7 -1
- package/dist/api/guards.d.ts.map +1 -1
- package/dist/api/guards.js +54 -0
- package/dist/api/guards.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +0 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/permissions.d.ts +1 -0
- package/dist/api/permissions.d.ts.map +1 -1
- package/dist/api/permissions.js +23 -20
- package/dist/api/permissions.js.map +1 -1
- package/dist/api/schema.d.ts.map +1 -1
- package/dist/api/schema.js +36 -22
- package/dist/api/schema.js.map +1 -1
- package/dist/authorization/branch.d.ts +14 -1
- package/dist/authorization/branch.d.ts.map +1 -1
- package/dist/authorization/branch.js +5 -3
- package/dist/authorization/branch.js.map +1 -1
- package/dist/authorization/groups/index.d.ts +1 -1
- package/dist/authorization/groups/index.d.ts.map +1 -1
- package/dist/authorization/groups/index.js +1 -1
- package/dist/authorization/groups/index.js.map +1 -1
- package/dist/authorization/groups/loader.d.ts +25 -4
- package/dist/authorization/groups/loader.d.ts.map +1 -1
- package/dist/authorization/groups/loader.js +39 -20
- package/dist/authorization/groups/loader.js.map +1 -1
- package/dist/authorization/groups/schema.d.ts +8 -7
- package/dist/authorization/groups/schema.d.ts.map +1 -1
- package/dist/authorization/groups/schema.js +8 -4
- package/dist/authorization/groups/schema.js.map +1 -1
- package/dist/authorization/index.d.ts +5 -3
- package/dist/authorization/index.d.ts.map +1 -1
- package/dist/authorization/index.js +8 -3
- package/dist/authorization/index.js.map +1 -1
- package/dist/authorization/path.d.ts +8 -0
- package/dist/authorization/path.d.ts.map +1 -1
- package/dist/authorization/path.js +16 -1
- package/dist/authorization/path.js.map +1 -1
- package/dist/authorization/permissions/index.d.ts +1 -1
- package/dist/authorization/permissions/index.d.ts.map +1 -1
- package/dist/authorization/permissions/index.js +1 -1
- package/dist/authorization/permissions/index.js.map +1 -1
- package/dist/authorization/permissions/loader.d.ts +18 -4
- package/dist/authorization/permissions/loader.d.ts.map +1 -1
- package/dist/authorization/permissions/loader.js +35 -24
- package/dist/authorization/permissions/loader.js.map +1 -1
- package/dist/authorization/permissions/schema.d.ts +8 -7
- package/dist/authorization/permissions/schema.d.ts.map +1 -1
- package/dist/authorization/permissions/schema.js +8 -4
- package/dist/authorization/permissions/schema.js.map +1 -1
- package/dist/authorization/protected-branch.d.ts +39 -0
- package/dist/authorization/protected-branch.d.ts.map +1 -0
- package/dist/authorization/protected-branch.js +43 -0
- package/dist/authorization/protected-branch.js.map +1 -0
- package/dist/authorization/settings-file-store.d.ts +127 -0
- package/dist/authorization/settings-file-store.d.ts.map +1 -0
- package/dist/authorization/settings-file-store.js +156 -0
- package/dist/authorization/settings-file-store.js.map +1 -0
- package/dist/branch-health.d.ts +78 -0
- package/dist/branch-health.d.ts.map +1 -0
- package/dist/branch-health.js +151 -0
- package/dist/branch-health.js.map +1 -0
- package/dist/branch-metadata.d.ts +31 -0
- package/dist/branch-metadata.d.ts.map +1 -1
- package/dist/branch-metadata.js +43 -1
- package/dist/branch-metadata.js.map +1 -1
- package/dist/branch-registry.d.ts.map +1 -1
- package/dist/branch-registry.js +13 -1
- package/dist/branch-registry.js.map +1 -1
- package/dist/cli/cli.js +398 -200
- package/dist/cli/generate-ai-content.js +72 -13
- package/dist/cli/init.js +84 -2
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +39 -11
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +4 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schemas/config.d.ts +55 -5
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/config.js +18 -1
- package/dist/config/schemas/config.js.map +1 -1
- package/dist/config/types.d.ts +11 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/content-store.d.ts +16 -0
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +22 -6
- package/dist/content-store.js.map +1 -1
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +25 -3
- package/dist/context.js.map +1 -1
- package/dist/editor/BranchManager.d.ts +11 -0
- package/dist/editor/BranchManager.d.ts.map +1 -1
- package/dist/editor/BranchManager.js +34 -10
- package/dist/editor/BranchManager.js.map +1 -1
- package/dist/editor/CommentsPanel.d.ts.map +1 -1
- package/dist/editor/CommentsPanel.js +4 -12
- package/dist/editor/CommentsPanel.js.map +1 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +46 -10
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/EntryNavigator.d.ts +7 -0
- package/dist/editor/EntryNavigator.d.ts.map +1 -1
- package/dist/editor/EntryNavigator.js +17 -5
- package/dist/editor/EntryNavigator.js.map +1 -1
- package/dist/editor/FormRenderer.d.ts.map +1 -1
- package/dist/editor/FormRenderer.js +4 -0
- package/dist/editor/FormRenderer.js.map +1 -1
- package/dist/editor/admin/SystemHealthPanel.d.ts +6 -0
- package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -0
- package/dist/editor/admin/SystemHealthPanel.js +234 -0
- package/dist/editor/admin/SystemHealthPanel.js.map +1 -0
- package/dist/editor/admin/useSystemHealth.d.ts +40 -0
- package/dist/editor/admin/useSystemHealth.d.ts.map +1 -0
- package/dist/editor/admin/useSystemHealth.js +205 -0
- package/dist/editor/admin/useSystemHealth.js.map +1 -0
- package/dist/editor/comments/FieldWrapper.d.ts.map +1 -1
- package/dist/editor/comments/FieldWrapper.js +1 -1
- package/dist/editor/comments/FieldWrapper.js.map +1 -1
- package/dist/editor/comments/InlineCommentThread.d.ts.map +1 -1
- package/dist/editor/comments/InlineCommentThread.js +2 -21
- package/dist/editor/comments/InlineCommentThread.js.map +1 -1
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -0
- package/dist/editor/comments/ThreadCarousel.d.ts.map +1 -1
- package/dist/editor/comments/ThreadCarousel.js +5 -2
- package/dist/editor/comments/ThreadCarousel.js.map +1 -1
- package/dist/editor/components/EditorHeader.d.ts +11 -0
- package/dist/editor/components/EditorHeader.d.ts.map +1 -1
- package/dist/editor/components/EditorHeader.js +75 -41
- package/dist/editor/components/EditorHeader.js.map +1 -1
- package/dist/editor/components/EditorSidebar.d.ts +7 -1
- package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
- package/dist/editor/components/EditorSidebar.js +3 -3
- package/dist/editor/components/EditorSidebar.js.map +1 -1
- package/dist/editor/fields/ImageField.d.ts.map +1 -1
- package/dist/editor/fields/ImageField.js +4 -1
- package/dist/editor/fields/ImageField.js.map +1 -1
- package/dist/editor/fields/StringListField.d.ts +20 -0
- package/dist/editor/fields/StringListField.d.ts.map +1 -0
- package/dist/editor/fields/StringListField.js +25 -0
- package/dist/editor/fields/StringListField.js.map +1 -0
- package/dist/editor/hooks/useBranchActions.d.ts.map +1 -1
- package/dist/editor/hooks/useBranchActions.js +8 -2
- package/dist/editor/hooks/useBranchActions.js.map +1 -1
- package/dist/editor/hooks/useBranchManager.d.ts +14 -3
- package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
- package/dist/editor/hooks/useBranchManager.js +18 -1
- package/dist/editor/hooks/useBranchManager.js.map +1 -1
- package/dist/editor/hooks/useDraftManager.d.ts +7 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +57 -3
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
- package/dist/editor/hooks/useEntryManager.js +25 -7
- package/dist/editor/hooks/useEntryManager.js.map +1 -1
- package/dist/editor/hooks/useGroupManager.d.ts.map +1 -1
- package/dist/editor/hooks/useGroupManager.js +13 -2
- package/dist/editor/hooks/useGroupManager.js.map +1 -1
- package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
- package/dist/editor/hooks/usePermissionManager.js +13 -2
- package/dist/editor/hooks/usePermissionManager.js.map +1 -1
- package/dist/editor/media/AssetCard.d.ts.map +1 -1
- package/dist/editor/media/AssetCard.js +23 -2
- package/dist/editor/media/AssetCard.js.map +1 -1
- package/dist/editor/relative-time.d.ts +8 -0
- package/dist/editor/relative-time.d.ts.map +1 -0
- package/dist/editor/relative-time.js +26 -0
- package/dist/editor/relative-time.js.map +1 -0
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +16 -2
- package/dist/git-manager.js.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/handler.js +20 -9
- package/dist/http/handler.js.map +1 -1
- package/dist/http/router.d.ts.map +1 -1
- package/dist/http/router.js +2 -0
- package/dist/http/router.js.map +1 -1
- package/dist/paths/branch-name.d.ts +20 -0
- package/dist/paths/branch-name.d.ts.map +1 -0
- package/dist/paths/branch-name.js +24 -0
- package/dist/paths/branch-name.js.map +1 -0
- package/dist/paths/branch.d.ts +2 -8
- package/dist/paths/branch.d.ts.map +1 -1
- package/dist/paths/branch.js +6 -12
- package/dist/paths/branch.js.map +1 -1
- package/dist/paths/index.d.ts +2 -1
- package/dist/paths/index.d.ts.map +1 -1
- package/dist/paths/index.js +4 -1
- package/dist/paths/index.js.map +1 -1
- package/dist/schema/schema-store.d.ts +98 -0
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +187 -29
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/services.js +9 -1
- package/dist/services.js.map +1 -1
- package/dist/task-queue/index.d.ts +2 -2
- package/dist/task-queue/index.d.ts.map +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/index.js.map +1 -1
- package/dist/task-queue/task-queue.d.ts +37 -1
- package/dist/task-queue/task-queue.d.ts.map +1 -1
- package/dist/task-queue/task-queue.js +104 -0
- package/dist/task-queue/task-queue.js.map +1 -1
- package/dist/task-queue/types.d.ts +11 -0
- package/dist/task-queue/types.d.ts.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/occ-json-write.d.ts.map +1 -1
- package/dist/utils/occ-json-write.js +7 -0
- package/dist/utils/occ-json-write.js.map +1 -1
- package/dist/utils/provisioning-lock.d.ts +19 -0
- package/dist/utils/provisioning-lock.d.ts.map +1 -1
- package/dist/utils/provisioning-lock.js +26 -0
- package/dist/utils/provisioning-lock.js.map +1 -1
- package/dist/validation/reference-validator.d.ts +17 -1
- package/dist/validation/reference-validator.d.ts.map +1 -1
- package/dist/validation/reference-validator.js +33 -10
- package/dist/validation/reference-validator.js.map +1 -1
- package/dist/worker/cms-worker.d.ts +74 -0
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +542 -29
- package/dist/worker/cms-worker.js.map +1 -1
- package/dist/worker/task-queue.d.ts +3 -3
- package/dist/worker/task-queue.d.ts.map +1 -1
- package/dist/worker/task-queue.js +2 -2
- package/dist/worker/task-queue.js.map +1 -1
- package/dist/worker/worker-status.d.ts +48 -0
- package/dist/worker/worker-status.d.ts.map +1 -0
- package/dist/worker/worker-status.js +53 -0
- package/dist/worker/worker-status.js.map +1 -0
- package/package.json +1 -1
|
@@ -4,14 +4,39 @@ import { simpleGit } from 'simple-git';
|
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
5
|
import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
|
|
6
6
|
import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
|
|
7
|
-
import { getBranchMetadataFileManager, BranchMetadataFileManager } from '../branch-metadata.js';
|
|
7
|
+
import { getBranchMetadataFileManager, BranchMetadataFileManager, buildMergedBranchUpdate, } from '../branch-metadata.js';
|
|
8
8
|
import { extractIdFromFilename } from '../content-id-index.js';
|
|
9
9
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
10
10
|
import { ROOT_COLLECTION_ID } from '../paths/types.js';
|
|
11
|
-
import {
|
|
11
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
12
|
+
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
13
|
+
import { writeWorkerStatus } from './worker-status.js';
|
|
12
14
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
13
15
|
const DEFAULT_MAX_RETRIES = 3;
|
|
14
16
|
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
17
|
+
/**
|
|
18
|
+
* [C1] Retention window for `.trash-*` branch directories left behind by the
|
|
19
|
+
* admin purge action (api/admin-branch-health.ts). Matches
|
|
20
|
+
* cleanupOldTasks's default task retention for consistency.
|
|
21
|
+
*/
|
|
22
|
+
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
|
23
|
+
/** Matches `.trash-{dirName}-{STAMP}` names, capturing the trailing stamp. */
|
|
24
|
+
const TRASH_DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
|
|
25
|
+
/**
|
|
26
|
+
* Parse a purge-generated `YYYYMMDDTHHMMSSZ` stamp into a Date, or null if
|
|
27
|
+
* malformed. Age comes ONLY from this name-embedded stamp, never the dir's
|
|
28
|
+
* own mtime -- `fs.rename` preserves the original directory's mtime, so an
|
|
29
|
+
* mtime-based retention check would delete a months-stale orphan's trash on
|
|
30
|
+
* the very first cleanup pass after purge.
|
|
31
|
+
*/
|
|
32
|
+
function parseTrashStamp(stamp) {
|
|
33
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
|
34
|
+
if (!match)
|
|
35
|
+
return null;
|
|
36
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
37
|
+
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
|
|
38
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
39
|
+
}
|
|
15
40
|
/**
|
|
16
41
|
* An error inherent to the task itself (malformed payload, unknown action):
|
|
17
42
|
* retrying can never succeed, so the task should fail fast instead of
|
|
@@ -135,6 +160,7 @@ export class CmsWorker {
|
|
|
135
160
|
this.remoteGitPath = path.join(config.workspacePath, 'remote.git');
|
|
136
161
|
this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
|
|
137
162
|
this.baseBranch = config.baseBranch ?? 'main';
|
|
163
|
+
this.sanitizedBaseBranch = sanitizeBranchName(this.baseBranch);
|
|
138
164
|
this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
|
|
139
165
|
this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
|
|
140
166
|
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
@@ -142,9 +168,25 @@ export class CmsWorker {
|
|
|
142
168
|
this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
143
169
|
this.contentRoot = config.contentRoot ?? 'content';
|
|
144
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Lazily get (and initialize if necessary) this worker's self-reported
|
|
173
|
+
* status object (PR-W1). Normally set once, up front, at the top of
|
|
174
|
+
* start(). The lazy fallback here covers two cases: (1) something in
|
|
175
|
+
* start() reaching a status-write point before that normal init runs
|
|
176
|
+
* (defensive -- see start()'s catch), and (2) unit tests that exercise
|
|
177
|
+
* syncGit()/processTaskQueue() directly without calling start() first.
|
|
178
|
+
*/
|
|
179
|
+
ensureStatusReport() {
|
|
180
|
+
if (!this.statusReport) {
|
|
181
|
+
const now = new Date().toISOString();
|
|
182
|
+
this.statusReport = { version: 1, startedAt: now, updatedAt: now };
|
|
183
|
+
}
|
|
184
|
+
return this.statusReport;
|
|
185
|
+
}
|
|
145
186
|
async start() {
|
|
146
187
|
this.running = true;
|
|
147
188
|
console.log('CMS Worker starting...');
|
|
189
|
+
this.ensureStatusReport();
|
|
148
190
|
// Acquire lock to prevent concurrent workers
|
|
149
191
|
await this.acquireLock();
|
|
150
192
|
// Everything below runs while holding the cross-host worker lock. A
|
|
@@ -175,6 +217,25 @@ export class CmsWorker {
|
|
|
175
217
|
await Promise.allSettled(initialTasks);
|
|
176
218
|
}
|
|
177
219
|
catch (err) {
|
|
220
|
+
// PR-W1: surface a startup failure (e.g. the empty-remote guard's
|
|
221
|
+
// poisoned remote.git) to the admin panel via worker-status.json,
|
|
222
|
+
// not only journald/CloudWatch. Best-effort and BEFORE releaseLock():
|
|
223
|
+
// a status-write failure must never block releasing the lock.
|
|
224
|
+
const report = this.ensureStatusReport();
|
|
225
|
+
report.lastFatalError = {
|
|
226
|
+
// [HIGH-1] Persisted to worker-status.json and served to the
|
|
227
|
+
// browser by the admin panel -- must never carry the bot token
|
|
228
|
+
// that a poisoned/failed git URL (buildGitHubUrl()) can embed.
|
|
229
|
+
message: redactCredentials(getErrorMessage(err)),
|
|
230
|
+
at: new Date().toISOString(),
|
|
231
|
+
phase: 'startup',
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
await writeWorkerStatus(this.taskDir, report);
|
|
235
|
+
}
|
|
236
|
+
catch (writeErr) {
|
|
237
|
+
console.error('Failed to write worker status on startup failure:', getErrorMessage(writeErr));
|
|
238
|
+
}
|
|
178
239
|
await this.releaseLock();
|
|
179
240
|
throw err;
|
|
180
241
|
}
|
|
@@ -396,6 +457,11 @@ export class CmsWorker {
|
|
|
396
457
|
catch (err) {
|
|
397
458
|
const message = getErrorMessage(err);
|
|
398
459
|
console.error(`Task ${task.id} (${task.action}) failed:`, message);
|
|
460
|
+
// [HIGH-1] task.error is persisted (pending/failed task JSON) and
|
|
461
|
+
// served to the browser by the admin panel's Tasks tab -- a push
|
|
462
|
+
// failure's message can embed the bot token via buildGitHubUrl().
|
|
463
|
+
// Console output above stays raw (journald/CloudWatch is trusted).
|
|
464
|
+
const persistedMessage = redactCredentials(message);
|
|
399
465
|
// DEP-L1: only transient failures (network, 429/5xx, timeouts) are
|
|
400
466
|
// worth retrying; permanent ones (malformed payload, other 4xx) would
|
|
401
467
|
// just burn the retry budget on an identical doomed request.
|
|
@@ -403,12 +469,12 @@ export class CmsWorker {
|
|
|
403
469
|
const retryCount = task.retryCount ?? 0;
|
|
404
470
|
const maxRetries = task.maxRetries ?? this.maxRetries;
|
|
405
471
|
if (!permanent && retryCount < maxRetries) {
|
|
406
|
-
await retryTask(this.taskDir, task.id,
|
|
472
|
+
await retryTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
407
473
|
console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
|
|
408
474
|
}
|
|
409
475
|
else {
|
|
410
|
-
await failTask(this.taskDir, task.id,
|
|
411
|
-
await this.updateBranchMetadataOnFailure(task,
|
|
476
|
+
await failTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
477
|
+
await this.updateBranchMetadataOnFailure(task, persistedMessage);
|
|
412
478
|
console.error(permanent
|
|
413
479
|
? ' Permanently failed (non-retryable error)'
|
|
414
480
|
: ` Permanently failed after ${maxRetries} retries`);
|
|
@@ -416,6 +482,15 @@ export class CmsWorker {
|
|
|
416
482
|
}
|
|
417
483
|
processed++;
|
|
418
484
|
}
|
|
485
|
+
// PR-W1: only stamp/write when work actually happened this poll --
|
|
486
|
+
// otherwise every idle 5s poll would hit worker-status.json, an EFS
|
|
487
|
+
// write treadmill for no signal (liveness is already covered by the
|
|
488
|
+
// lock heartbeat; see api/admin.ts's classifyWorkerLiveness).
|
|
489
|
+
if (processed > 0) {
|
|
490
|
+
const report = this.ensureStatusReport();
|
|
491
|
+
report.lastTaskCycleAt = new Date().toISOString();
|
|
492
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
493
|
+
}
|
|
419
494
|
}
|
|
420
495
|
/**
|
|
421
496
|
* Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
|
|
@@ -491,13 +566,23 @@ export class CmsWorker {
|
|
|
491
566
|
// direct-API path) so the list->tiebreak->update/create logic and
|
|
492
567
|
// the draft->ready conversion live in one place.
|
|
493
568
|
const branch = requireString(payload, 'branch');
|
|
569
|
+
const base = optionalString(payload, 'baseBranch', this.baseBranch);
|
|
570
|
+
// Defense-in-depth: refuse head===base even if the 'submittableBranch'
|
|
571
|
+
// API guard and the syncSubmitPr backstop were both somehow bypassed
|
|
572
|
+
// (e.g. a task queued before this check shipped). PermanentTaskError
|
|
573
|
+
// (not a plain Error) so this fails immediately instead of burning
|
|
574
|
+
// the retry budget on an identical doomed request -- retrying can
|
|
575
|
+
// never make the branch not be the base branch.
|
|
576
|
+
if (sanitizeBranchName(branch) === this.sanitizedBaseBranch) {
|
|
577
|
+
throw new PermanentTaskError(`Refusing to push-and-create-or-update-pr for "${branch}": it is the base branch -- submitting the base branch is never valid`);
|
|
578
|
+
}
|
|
494
579
|
await this.pushBranchToGitHub(branch);
|
|
495
580
|
const result = await createOrUpdatePullRequest({
|
|
496
581
|
octokit: this.octokit,
|
|
497
582
|
owner: this.config.githubOwner,
|
|
498
583
|
repo: this.config.githubRepo,
|
|
499
584
|
head: branch,
|
|
500
|
-
base
|
|
585
|
+
base,
|
|
501
586
|
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
502
587
|
body: optionalString(payload, 'body', ''),
|
|
503
588
|
// Content submits (api/github-sync.ts) set this; settings-branch
|
|
@@ -572,8 +657,21 @@ export class CmsWorker {
|
|
|
572
657
|
};
|
|
573
658
|
if (result.prUrl)
|
|
574
659
|
updates.pullRequestUrl = result.prUrl;
|
|
575
|
-
if (result.prNumber)
|
|
660
|
+
if (result.prNumber) {
|
|
576
661
|
updates.pullRequestNumber = result.prNumber;
|
|
662
|
+
// As soon as the PR exists the field should read 'open' -- without
|
|
663
|
+
// this it would stay absent until the next poll cycle observes it.
|
|
664
|
+
// Exception: the git-sync loop may have archived this branch (PR
|
|
665
|
+
// merged/closed) while this task was in flight; a late task
|
|
666
|
+
// completion must not downgrade that terminal state back to 'open'.
|
|
667
|
+
const current = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
668
|
+
const terminal = current?.branch.status === 'archived' ||
|
|
669
|
+
current?.branch.pullRequestState === 'merged' ||
|
|
670
|
+
current?.branch.pullRequestState === 'closed';
|
|
671
|
+
if (!terminal) {
|
|
672
|
+
updates.pullRequestState = 'open';
|
|
673
|
+
}
|
|
674
|
+
}
|
|
577
675
|
await meta.save({ branch: updates });
|
|
578
676
|
}
|
|
579
677
|
catch (err) {
|
|
@@ -645,6 +743,7 @@ export class CmsWorker {
|
|
|
645
743
|
if (!this.running)
|
|
646
744
|
return;
|
|
647
745
|
console.log('Syncing git...');
|
|
746
|
+
const cycleStartedAt = Date.now();
|
|
648
747
|
const git = simpleGit({
|
|
649
748
|
baseDir: this.remoteGitPath,
|
|
650
749
|
// DEP-H1: a hung fetch/push would stall the sync loop forever
|
|
@@ -652,35 +751,384 @@ export class CmsWorker {
|
|
|
652
751
|
// is inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
653
752
|
timeout: { block: this.taskTimeoutMs },
|
|
654
753
|
});
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
754
|
+
// PR-W1: the whole cycle is wrapped so both outcomes -- success and
|
|
755
|
+
// hard failure (e.g. the fetch throwing against a poisoned remote.git)
|
|
756
|
+
// -- record a worker-status.json snapshot. The status write itself is
|
|
757
|
+
// always best-effort (.catch below): it must never turn an otherwise
|
|
758
|
+
// successful cycle into a failure, and must never mask the real error
|
|
759
|
+
// on a failed one. On failure we rethrow so scheduleLoop's existing
|
|
760
|
+
// per-cycle catch stays the loud path.
|
|
761
|
+
try {
|
|
762
|
+
// Fetch all branches from GitHub using direct URL (no named remote)
|
|
763
|
+
// We use raw git commands since simple-git's fetch() with a URL
|
|
764
|
+
// doesn't support --prune directly
|
|
765
|
+
await git.raw(['fetch', this.buildGitHubUrl(), '--prune', '+refs/heads/*:refs/heads/*']);
|
|
766
|
+
console.log('Fetched from GitHub');
|
|
767
|
+
// Push settings branches to GitHub (belt-and-suspenders for task queue).
|
|
768
|
+
// Ensures settings reach GitHub even if a task queue entry is lost.
|
|
769
|
+
await this.pushSettingsBranches(git);
|
|
770
|
+
await this.refreshBaseBranchWorkspace();
|
|
771
|
+
const rebaseSummary = await this.rebaseActiveBranches();
|
|
772
|
+
// Periodically clean up old completed/failed tasks
|
|
773
|
+
await cleanupOldTasks(this.taskDir, undefined, this.log);
|
|
774
|
+
// [C1] Sweep branch directories the admin purge action trashed more
|
|
775
|
+
// than TRASH_RETENTION_MS ago. Worker-only by design: purge itself
|
|
776
|
+
// never deletes anything (reversible), and this cycle is the sole
|
|
777
|
+
// place actual removal happens.
|
|
778
|
+
const trashRemoved = await this.cleanupTrashedBranchDirs();
|
|
779
|
+
if (trashRemoved > 0) {
|
|
780
|
+
console.log(`Removed ${trashRemoved} expired trashed branch dir(s)`);
|
|
781
|
+
}
|
|
782
|
+
const report = this.ensureStatusReport();
|
|
783
|
+
report.lastGitSyncAt = new Date().toISOString();
|
|
784
|
+
delete report.lastGitSyncError;
|
|
785
|
+
report.lastGitSync = {
|
|
786
|
+
durationMs: Date.now() - cycleStartedAt,
|
|
787
|
+
rebased: rebaseSummary.rebased,
|
|
788
|
+
skippedDirty: rebaseSummary.skippedDirty,
|
|
789
|
+
failed: rebaseSummary.failed,
|
|
790
|
+
};
|
|
791
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
const report = this.ensureStatusReport();
|
|
795
|
+
// [HIGH-1] Persisted to worker-status.json and served to the browser
|
|
796
|
+
// by the admin panel -- a fetch/push failure's message can embed the
|
|
797
|
+
// bot token via buildGitHubUrl().
|
|
798
|
+
report.lastGitSyncError = {
|
|
799
|
+
message: redactCredentials(getErrorMessage(err)),
|
|
800
|
+
at: new Date().toISOString(),
|
|
801
|
+
};
|
|
802
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => console.error('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
803
|
+
throw err;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* [C1] Remove `.trash-*` branch directories (created by the admin purge
|
|
808
|
+
* action, api/admin-branch-health.ts) whose name-embedded stamp is older
|
|
809
|
+
* than {@link TRASH_RETENTION_MS}. Names that don't match the expected
|
|
810
|
+
* `.trash-{dirName}-{STAMP}` shape, or whose stamp fails to parse, are
|
|
811
|
+
* left alone (logged once per cycle, not per file, to avoid flooding logs
|
|
812
|
+
* if something odd accumulates) -- purge is the only writer of this
|
|
813
|
+
* naming scheme, so an unparseable name is unexpected and worth a human
|
|
814
|
+
* looking rather than a silent skip.
|
|
815
|
+
*/
|
|
816
|
+
async cleanupTrashedBranchDirs() {
|
|
817
|
+
let entries;
|
|
818
|
+
try {
|
|
819
|
+
entries = await fs.readdir(this.contentBranchesPath);
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
823
|
+
return 0;
|
|
824
|
+
throw err;
|
|
825
|
+
}
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
let removed = 0;
|
|
828
|
+
let loggedUnparseable = false;
|
|
829
|
+
for (const name of entries) {
|
|
830
|
+
if (!name.startsWith('.trash-'))
|
|
831
|
+
continue;
|
|
832
|
+
const stampMatch = TRASH_DIR_STAMP_RE.exec(name);
|
|
833
|
+
const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
|
|
834
|
+
if (!stampDate) {
|
|
835
|
+
if (!loggedUnparseable) {
|
|
836
|
+
console.log(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
|
|
837
|
+
loggedUnparseable = true;
|
|
838
|
+
}
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (now - stampDate.getTime() < TRASH_RETENTION_MS)
|
|
842
|
+
continue;
|
|
843
|
+
try {
|
|
844
|
+
await fs.rm(path.join(this.contentBranchesPath, name), {
|
|
845
|
+
recursive: true,
|
|
846
|
+
force: true,
|
|
847
|
+
maxRetries: 3,
|
|
848
|
+
retryDelay: 100,
|
|
849
|
+
});
|
|
850
|
+
removed++;
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
console.error(`CanopyCMS: Failed to remove trashed branch dir ${name}:`, getErrorMessage(err));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return removed;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Fast-forward the base branch's own working-tree clone
|
|
860
|
+
* (content-branches/<baseBranch>) to match origin/<baseBranch>.
|
|
861
|
+
*
|
|
862
|
+
* Previously this clone was refreshed only incidentally, by the generic
|
|
863
|
+
* rebase loop below (rebaseActiveBranches): for a branch with status
|
|
864
|
+
* 'editing', rebasing onto origin/<baseBranch> degenerates to a
|
|
865
|
+
* fast-forward when the clone IS the base branch. But that loop's skip
|
|
866
|
+
* paths -- a dirty tree, a missing .git -- are silent, which is the
|
|
867
|
+
* suspected live failure mode: a wedged base clone with no diagnosable
|
|
868
|
+
* signal in the logs. This dedicated step makes the refresh explicit,
|
|
869
|
+
* ff-only, and loud, so a stuck base view (an editor forking a new branch
|
|
870
|
+
* "from base" that's actually a stale snapshot) is diagnosable from logs.
|
|
871
|
+
* This runs every sync cycle so the drift window is bounded by
|
|
872
|
+
* gitSyncInterval.
|
|
873
|
+
*
|
|
874
|
+
* ff-only on purpose: this clone must stay a linear mirror of
|
|
875
|
+
* origin/<baseBranch>, so a merge that isn't a fast-forward (diverged
|
|
876
|
+
* local history) is treated as a should-never-happen condition and left
|
|
877
|
+
* untouched rather than force-resolved.
|
|
878
|
+
*/
|
|
879
|
+
async refreshBaseBranchWorkspace() {
|
|
880
|
+
// Sanitized name for the workspace directory (a base branch containing
|
|
881
|
+
// e.g. '/' would otherwise stat a wrong nested path here forever).
|
|
882
|
+
const basePath = path.join(this.contentBranchesPath, this.sanitizedBaseBranch);
|
|
883
|
+
const gitDir = path.join(basePath, '.git');
|
|
884
|
+
try {
|
|
885
|
+
let gitDirStat;
|
|
886
|
+
try {
|
|
887
|
+
gitDirStat = await fs.stat(gitDir);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
gitDirStat = null;
|
|
891
|
+
}
|
|
892
|
+
if (!gitDirStat || !gitDirStat.isDirectory()) {
|
|
893
|
+
console.log(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const baseGit = simpleGit({
|
|
897
|
+
baseDir: basePath,
|
|
898
|
+
// Keep git non-interactive during the merge so it never blocks on an
|
|
899
|
+
// editor. simple-git >=3.32 blocks setting core.editor unless
|
|
900
|
+
// explicitly opted in; the value here is a hardcoded literal
|
|
901
|
+
// ("true", the shell no-op), not user input, so enabling
|
|
902
|
+
// allowUnsafeEditor carries no injection risk (same as the rebase
|
|
903
|
+
// loop's git config below).
|
|
904
|
+
config: ['core.editor=true'],
|
|
905
|
+
unsafe: { allowUnsafeEditor: true },
|
|
906
|
+
// DEP-H1: a hung fetch/merge against this EFS-backed clone would
|
|
907
|
+
// stall the sync loop forever (scheduleLoop only reschedules after
|
|
908
|
+
// completion). The block timeout is inactivity-based, so a
|
|
909
|
+
// slow-but-flowing transfer is unaffected -- same as syncGit()'s
|
|
910
|
+
// remote handle.
|
|
911
|
+
timeout: { block: this.taskTimeoutMs },
|
|
912
|
+
});
|
|
913
|
+
// Nothing makes this clone read-only. A direct edit here (or a stray
|
|
914
|
+
// process) wedges every editor's view of the base branch until an
|
|
915
|
+
// operator intervenes, so a dirty tree is loud, not a quiet skip.
|
|
916
|
+
// Only TRACKED changes block the refresh: a stray untracked file (e.g.
|
|
917
|
+
// runtime metadata missing from .git/info/exclude) must not wedge the
|
|
918
|
+
// fast-forward forever — if an untracked file would collide with
|
|
919
|
+
// incoming content, the --ff-only merge below refuses on its own and
|
|
920
|
+
// that failure is already logged loudly.
|
|
921
|
+
const status = await baseGit.status();
|
|
922
|
+
const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
|
|
923
|
+
if (trackedDirty.length > 0) {
|
|
924
|
+
console.error(`Base branch workspace (${this.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
// Raw (unsanitized) name from here on: these are git ref operations
|
|
928
|
+
// against origin/<baseBranch>, not filesystem paths, so they must use
|
|
929
|
+
// the same name GitHub knows the branch by.
|
|
930
|
+
await baseGit.fetch('origin', this.baseBranch);
|
|
931
|
+
// Use rev-list instead of status.behind for the same reason as the
|
|
932
|
+
// rebase loop below: status.behind only works with an upstream
|
|
933
|
+
// tracking branch configured, which isn't guaranteed here.
|
|
934
|
+
// Compare against the just-fetched tip rather than origin/<base>:
|
|
935
|
+
// workspaces are cloned --single-branch (git-manager.ts), so their
|
|
936
|
+
// fetch refspec only materializes a remote-tracking ref for the branch
|
|
937
|
+
// they were cloned from — for any other base branch, origin/<base>
|
|
938
|
+
// simply never exists and rev-list dies with "ambiguous argument".
|
|
939
|
+
// Pin FETCH_HEAD to a SHA immediately: FETCH_HEAD is one shared mutable
|
|
940
|
+
// file per repo, silently repointed by ANY other fetch in this clone.
|
|
941
|
+
const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
|
|
942
|
+
const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
|
|
943
|
+
if (behindCount > 0) {
|
|
944
|
+
try {
|
|
945
|
+
await baseGit.merge(['--ff-only', fetchedTip]);
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
await invalidateBranchContentCaches(basePath);
|
|
952
|
+
}
|
|
953
|
+
// Hygiene: conflictStatus/conflictFiles are meaningless for the base
|
|
954
|
+
// branch's own metadata and may be left over from before the base was
|
|
955
|
+
// excluded from rebaseActiveBranches()'s conflict-resolution loop.
|
|
956
|
+
// Reuse the already-clean no-op-save guard from that loop: save()
|
|
957
|
+
// eager-regenerates the branch registry (O(branch count) EFS reads),
|
|
958
|
+
// so skip it when there's nothing to clear.
|
|
959
|
+
const currentMeta = await BranchMetadataFileManager.loadOnly(basePath);
|
|
960
|
+
const conflictStatus = currentMeta?.branch.conflictStatus;
|
|
961
|
+
const conflictFiles = currentMeta?.branch.conflictFiles;
|
|
962
|
+
const alreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
|
|
963
|
+
(conflictFiles === undefined || conflictFiles.length === 0);
|
|
964
|
+
if (!alreadyClean) {
|
|
965
|
+
const meta = getBranchMetadataFileManager(basePath, this.contentBranchesPath);
|
|
966
|
+
await meta.save({
|
|
967
|
+
branch: { name: this.baseBranch, conflictStatus: 'clean', conflictFiles: [] },
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
// One concise per-cycle line -- the diagnostic for the next live deploy.
|
|
971
|
+
console.log(behindCount > 0
|
|
972
|
+
? `Base branch workspace (${this.baseBranch}): fast-forwarded ${behindCount} commit(s)`
|
|
973
|
+
: `Base branch workspace (${this.baseBranch}): up to date`);
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
console.error(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Poll GitHub for a submitted/approved branch's PR resolution.
|
|
981
|
+
*
|
|
982
|
+
* submitted/approved branches sit outside the rebase loop and get no
|
|
983
|
+
* other signal that their PR resolved on GitHub -- nothing pushes a
|
|
984
|
+
* merge/close webhook back into the branch workspace. merged ->
|
|
985
|
+
* auto-archive via buildMergedBranchUpdate (shared with the manual
|
|
986
|
+
* markAsMerged API so both paths produce identical archived-branch
|
|
987
|
+
* metadata). closed-without-merge -> record pullRequestState only; an
|
|
988
|
+
* admin decides the workflow transition from there. Best-effort: any
|
|
989
|
+
* failure here is logged and swallowed, retried next sync cycle.
|
|
990
|
+
*/
|
|
991
|
+
async pollMergeState(branchDir, branchPath, metaFile) {
|
|
992
|
+
const prNumber = metaFile?.branch.pullRequestNumber;
|
|
993
|
+
if (!prNumber)
|
|
994
|
+
return;
|
|
995
|
+
const controller = new AbortController();
|
|
996
|
+
const timer = setTimeout(() => controller.abort(), this.taskTimeoutMs);
|
|
997
|
+
try {
|
|
998
|
+
const { data } = await this.octokit.pulls.get({
|
|
999
|
+
owner: this.config.githubOwner,
|
|
1000
|
+
repo: this.config.githubRepo,
|
|
1001
|
+
pull_number: prNumber,
|
|
1002
|
+
request: { signal: controller.signal },
|
|
1003
|
+
});
|
|
1004
|
+
if (data.merged) {
|
|
1005
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
1006
|
+
// Use GitHub's actual merge time when available; buildMergedBranchUpdate
|
|
1007
|
+
// falls back to "now" (its default `now` param) when merged_at is absent.
|
|
1008
|
+
await meta.save({
|
|
1009
|
+
branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
|
|
1010
|
+
});
|
|
1011
|
+
console.log(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
1015
|
+
// Re-load fresh (not the loop-top `metaFile` snapshot passed in) -- a
|
|
1016
|
+
// concurrent Lambda write (e.g. an editor re-submitting) may have
|
|
1017
|
+
// landed since that snapshot was taken.
|
|
1018
|
+
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1019
|
+
if (currentMeta?.branch.pullRequestState === newState)
|
|
1020
|
+
return;
|
|
1021
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
1022
|
+
await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
|
|
1023
|
+
console.log(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
|
|
1024
|
+
}
|
|
1025
|
+
catch (err) {
|
|
1026
|
+
// Non-fatal: transient GitHub/network errors are retried next cycle.
|
|
1027
|
+
console.warn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
clearTimeout(timer);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Persist a per-branch rebase failure to branch.json (PR-W2), bounded to
|
|
1035
|
+
* roughly one save per failing branch per hour: a branch stuck failing
|
|
1036
|
+
* every cycle must not turn into unbounded save-per-cycle x N-failing-
|
|
1037
|
+
* branches write amplification -- save() eager-regenerates the branch
|
|
1038
|
+
* registry (branch-metadata.ts's invalidateRegistry(), O(branch count) EFS
|
|
1039
|
+
* reads), the same concern the `alreadyClean` no-op guard above exists
|
|
1040
|
+
* for.
|
|
1041
|
+
*
|
|
1042
|
+
* Best-effort and non-fatal like every other metadata write in this
|
|
1043
|
+
* loop's error paths: a corrupt branch.json, a lock-contention error, or
|
|
1044
|
+
* any other save failure here must never abort the per-branch iteration.
|
|
1045
|
+
* This matters doubly at the two call sites -- one is inside the outer
|
|
1046
|
+
* per-branch catch, with no further catch of its own around this call --
|
|
1047
|
+
* so the whole method is wrapped, not just the load.
|
|
1048
|
+
*/
|
|
1049
|
+
async recordRebaseFailure(branchPath, branchDir, message) {
|
|
1050
|
+
const RECORD_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
|
1051
|
+
// [HIGH-1] Defense-in-depth redaction: rebaseFailure.message is
|
|
1052
|
+
// persisted to branch.json and served to the browser via the
|
|
1053
|
+
// branch-health admin endpoint. Both call sites in rebaseActiveBranches
|
|
1054
|
+
// already redact before passing in (the failed.push sites below),
|
|
1055
|
+
// redactCredentials is idempotent, so redacting again here is free and
|
|
1056
|
+
// keeps this method safe on its own.
|
|
1057
|
+
const redactedMessage = redactCredentials(message);
|
|
1058
|
+
try {
|
|
1059
|
+
const existing = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1060
|
+
const prior = existing?.branch.rebaseFailure;
|
|
1061
|
+
const sameMessage = prior?.message === redactedMessage;
|
|
1062
|
+
const now = new Date();
|
|
1063
|
+
if (sameMessage) {
|
|
1064
|
+
const lastAtMs = Date.parse(prior.lastAt);
|
|
1065
|
+
if (!Number.isNaN(lastAtMs) && now.getTime() - lastAtMs < RECORD_REFRESH_MS) {
|
|
1066
|
+
// Same failure, refreshed within the last hour -- skip the save.
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const nowIso = now.toISOString();
|
|
1071
|
+
const firstAt = sameMessage ? prior.firstAt : nowIso;
|
|
1072
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
1073
|
+
await meta.save({
|
|
1074
|
+
branch: {
|
|
1075
|
+
name: branchDir,
|
|
1076
|
+
rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso },
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
// Includes BranchMetadataCorruptError from the load above (a save()
|
|
1082
|
+
// against the same corrupt file would just throw again) as well as
|
|
1083
|
+
// any other load/save failure -- recording is best-effort
|
|
1084
|
+
// observability, never allowed to abort the branch loop.
|
|
1085
|
+
console.warn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
|
|
1086
|
+
}
|
|
666
1087
|
}
|
|
667
1088
|
async rebaseActiveBranches() {
|
|
1089
|
+
// PR-W1: collected across the loop below and returned as a summary
|
|
1090
|
+
// (folded into worker-status.json by syncGit()). Purely additive
|
|
1091
|
+
// bookkeeping -- doesn't change any control flow or existing logging.
|
|
1092
|
+
const rebased = [];
|
|
1093
|
+
const skippedDirty = [];
|
|
1094
|
+
const failed = [];
|
|
668
1095
|
let branchDirs;
|
|
669
1096
|
try {
|
|
670
1097
|
branchDirs = await fs.readdir(this.contentBranchesPath);
|
|
671
1098
|
}
|
|
672
1099
|
catch {
|
|
673
|
-
return;
|
|
1100
|
+
return { rebased, skippedDirty, failed };
|
|
674
1101
|
}
|
|
675
1102
|
for (const branchDir of branchDirs) {
|
|
1103
|
+
// Known structural entries under content-branches/, not branch
|
|
1104
|
+
// workspaces: branches.json (registry snapshot) and dot-prefixed
|
|
1105
|
+
// entries (.canopy-meta/, transient lock dirs). Skip them silently so
|
|
1106
|
+
// the no-.git skip logs below don't fire for them every cycle.
|
|
1107
|
+
// Everything else still logs loudly on skip.
|
|
1108
|
+
if (branchDir.startsWith('.') || branchDir === 'branches.json') {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
676
1111
|
const branchPath = path.join(this.contentBranchesPath, branchDir);
|
|
677
1112
|
const gitDir = path.join(branchPath, '.git');
|
|
678
1113
|
try {
|
|
679
1114
|
const stat = await fs.stat(gitDir);
|
|
680
|
-
if (!stat.isDirectory())
|
|
1115
|
+
if (!stat.isDirectory()) {
|
|
1116
|
+
console.log(` Skipping ${branchDir}: .git is not a directory`);
|
|
681
1117
|
continue;
|
|
1118
|
+
}
|
|
682
1119
|
}
|
|
683
1120
|
catch {
|
|
1121
|
+
console.log(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
// The base branch's own clone is refreshed ff-only by
|
|
1125
|
+
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it
|
|
1126
|
+
// through this conflict-resolution rebase loop could rewrite its
|
|
1127
|
+
// history (the --theirs loop below) and stamp meaningless conflict
|
|
1128
|
+
// metadata on it. Compare sanitized-vs-sanitized: branchDir is a
|
|
1129
|
+
// filesystem name (already sanitized), this.baseBranch is raw.
|
|
1130
|
+
if (branchDir === this.sanitizedBaseBranch) {
|
|
1131
|
+
console.log(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
684
1132
|
continue;
|
|
685
1133
|
}
|
|
686
1134
|
try {
|
|
@@ -688,11 +1136,17 @@ export class CmsWorker {
|
|
|
688
1136
|
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
689
1137
|
const branchStatus = metaFile?.branch.status;
|
|
690
1138
|
// Skip branches that shouldn't be mutated:
|
|
691
|
-
// - submitted/approved: in review, don't rewrite history under an
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1139
|
+
// - submitted/approved: in review, don't rewrite history under an
|
|
1140
|
+
// active PR -- but do poll GitHub for the PR's resolution, since
|
|
1141
|
+
// nothing else tells the worker a merge/close happened.
|
|
1142
|
+
// - archived: already merged, no reason to rebase and no PR left to
|
|
1143
|
+
// poll (avoid the wasted API call).
|
|
1144
|
+
if (branchStatus === 'submitted' || branchStatus === 'approved') {
|
|
1145
|
+
console.log(` Skipping ${branchDir} (${branchStatus})`);
|
|
1146
|
+
await this.pollMergeState(branchDir, branchPath, metaFile);
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
if (branchStatus === 'archived') {
|
|
696
1150
|
console.log(` Skipping ${branchDir} (${branchStatus})`);
|
|
697
1151
|
continue;
|
|
698
1152
|
}
|
|
@@ -712,13 +1166,20 @@ export class CmsWorker {
|
|
|
712
1166
|
const dirtyCheck = await branchGit.status();
|
|
713
1167
|
if (dirtyCheck.files.length > 0) {
|
|
714
1168
|
console.log(` Skipping ${branchDir}: has uncommitted changes`);
|
|
1169
|
+
skippedDirty.push(branchDir);
|
|
715
1170
|
continue;
|
|
716
1171
|
}
|
|
717
1172
|
await branchGit.fetch('origin', this.baseBranch);
|
|
718
1173
|
// Use rev-list instead of status.behind — status.behind only works when the
|
|
719
1174
|
// branch has an upstream tracking branch configured, which isn't guaranteed
|
|
720
1175
|
// (checkoutBranch fallback paths create branches without --track).
|
|
721
|
-
|
|
1176
|
+
// The just-fetched tip, not origin/<base>: branch clones are
|
|
1177
|
+
// --single-branch, so no remote-tracking ref exists for a base branch
|
|
1178
|
+
// other than the one they were cloned from (see the base-refresh
|
|
1179
|
+
// comment above). Pinned to a SHA immediately — FETCH_HEAD is one
|
|
1180
|
+
// shared mutable file per repo, repointed by any concurrent fetch.
|
|
1181
|
+
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
1182
|
+
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
722
1183
|
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
723
1184
|
if (behindCount === 0) {
|
|
724
1185
|
// Already in sync. This is the overwhelmingly common outcome per
|
|
@@ -735,8 +1196,13 @@ export class CmsWorker {
|
|
|
735
1196
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
736
1197
|
const conflictStatus = currentMeta?.branch.conflictStatus;
|
|
737
1198
|
const conflictFiles = currentMeta?.branch.conflictFiles;
|
|
738
|
-
const
|
|
1199
|
+
const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
|
|
739
1200
|
(conflictFiles === undefined || conflictFiles.length === 0);
|
|
1201
|
+
// PR-W2: a lingering rebaseFailure must also be cleared once the
|
|
1202
|
+
// branch catches up clean -- otherwise it sticks as a stale
|
|
1203
|
+
// warning forever (nothing else touches this branch once it's
|
|
1204
|
+
// caught up, so no other save site would ever clear it).
|
|
1205
|
+
const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
|
|
740
1206
|
if (alreadyClean) {
|
|
741
1207
|
continue;
|
|
742
1208
|
}
|
|
@@ -745,6 +1211,7 @@ export class CmsWorker {
|
|
|
745
1211
|
name: branchDir,
|
|
746
1212
|
conflictStatus: 'clean',
|
|
747
1213
|
conflictFiles: [],
|
|
1214
|
+
rebaseFailure: undefined,
|
|
748
1215
|
},
|
|
749
1216
|
});
|
|
750
1217
|
continue;
|
|
@@ -755,11 +1222,16 @@ export class CmsWorker {
|
|
|
755
1222
|
const conflictedFiles = [];
|
|
756
1223
|
let nextAction = 'start';
|
|
757
1224
|
let completed = false;
|
|
1225
|
+
// PR-W1: captured only on the "unexpected error" exit below, for the
|
|
1226
|
+
// failed-summary entry pushed at the `if (!completed)` check.
|
|
1227
|
+
let failureReason;
|
|
758
1228
|
const MAX_ROUNDS = 50; // safety limit against infinite loops
|
|
759
1229
|
for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
|
|
760
1230
|
try {
|
|
761
1231
|
if (nextAction === 'start') {
|
|
762
|
-
|
|
1232
|
+
// The pinned base tip fetched above (single-branch clones have
|
|
1233
|
+
// no origin/<base> remote-tracking ref for other branches).
|
|
1234
|
+
await branchGit.rebase([fetchedBaseTip]);
|
|
763
1235
|
}
|
|
764
1236
|
else if (nextAction === 'continue') {
|
|
765
1237
|
await branchGit.rebase(['--continue']);
|
|
@@ -796,6 +1268,7 @@ export class CmsWorker {
|
|
|
796
1268
|
// state. Previous metadata (possibly stale) is preserved until the
|
|
797
1269
|
// next successful rebase cycle corrects it.
|
|
798
1270
|
console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
1271
|
+
failureReason = msg || 'Unknown error';
|
|
799
1272
|
await branchGit.rebase(['--abort']).catch(() => { });
|
|
800
1273
|
break;
|
|
801
1274
|
}
|
|
@@ -803,8 +1276,30 @@ export class CmsWorker {
|
|
|
803
1276
|
}
|
|
804
1277
|
}
|
|
805
1278
|
if (!completed) {
|
|
806
|
-
|
|
1279
|
+
// PR-W2 (M1 rider): failureReason is only set on the "unexpected
|
|
1280
|
+
// error" break above -- MAX_ROUNDS exhaustion is a distinct exit
|
|
1281
|
+
// path with no error message of its own, so the warn text must
|
|
1282
|
+
// not conflate the two.
|
|
1283
|
+
console.warn(failureReason !== undefined
|
|
1284
|
+
? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
|
|
1285
|
+
: ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
|
|
807
1286
|
await branchGit.rebase(['--abort']).catch(() => { });
|
|
1287
|
+
const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_ROUNDS} rounds`;
|
|
1288
|
+
// [HIGH-1] failed[] folds into worker-status.json's
|
|
1289
|
+
// lastGitSync.failed, served to the browser -- failureReason can
|
|
1290
|
+
// be an arbitrary git error message that embeds the bot token.
|
|
1291
|
+
const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
|
|
1292
|
+
failed.push({
|
|
1293
|
+
branch: branchDir,
|
|
1294
|
+
error: redactedRebaseFailureMessage,
|
|
1295
|
+
});
|
|
1296
|
+
// PR-W2: record once here for the "!completed" exit -- the
|
|
1297
|
+
// unexpected-error break above is NOT disjoint from this block (it
|
|
1298
|
+
// always falls through here), so recording at the break itself
|
|
1299
|
+
// would double-record. The outer catch below is the only other
|
|
1300
|
+
// record site (a distinct, non-overlapping failure class: errors
|
|
1301
|
+
// outside this round loop, e.g. fetch/rev-list failures).
|
|
1302
|
+
await this.recordRebaseFailure(branchPath, branchDir, redactedRebaseFailureMessage);
|
|
808
1303
|
continue;
|
|
809
1304
|
}
|
|
810
1305
|
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
@@ -845,14 +1340,32 @@ export class CmsWorker {
|
|
|
845
1340
|
name: branchDir,
|
|
846
1341
|
conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
|
|
847
1342
|
conflictFiles: conflictIdsDeduped,
|
|
1343
|
+
// PR-W2: the cycle completed successfully -- clear any prior
|
|
1344
|
+
// failure record regardless of conflict outcome.
|
|
1345
|
+
rebaseFailure: undefined,
|
|
848
1346
|
},
|
|
849
1347
|
});
|
|
1348
|
+
// PR-W1: the branch was behind and the rebase completed (with or
|
|
1349
|
+
// without --theirs conflict resolution) -- it moved, so it belongs
|
|
1350
|
+
// in the summary. Branches already up to date `continue`d above and
|
|
1351
|
+
// are deliberately not listed here.
|
|
1352
|
+
rebased.push(branchDir);
|
|
850
1353
|
}
|
|
851
1354
|
catch (err) {
|
|
852
1355
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
853
1356
|
console.warn(` Failed to sync ${branchDir}: ${message}`);
|
|
1357
|
+
// [HIGH-1] Same rationale as the `if (!completed)` push site above --
|
|
1358
|
+
// this catches fetch/rev-list/unexpected errors, whose message can
|
|
1359
|
+
// embed the bot token.
|
|
1360
|
+
const redactedMessage = redactCredentials(message);
|
|
1361
|
+
failed.push({ branch: branchDir, error: redactedMessage });
|
|
1362
|
+
// PR-W2: second (and only other) record site -- see the comment at
|
|
1363
|
+
// the `if (!completed)` block above for why these two sites are
|
|
1364
|
+
// disjoint.
|
|
1365
|
+
await this.recordRebaseFailure(branchPath, branchDir, redactedMessage);
|
|
854
1366
|
}
|
|
855
1367
|
}
|
|
1368
|
+
return { rebased, skippedDirty, failed };
|
|
856
1369
|
}
|
|
857
1370
|
async refreshAuthCache() {
|
|
858
1371
|
if (!this.running || !this.config.refreshAuthCache)
|