canopycms 0.0.60 → 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 +51 -0
- 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 +13 -3
- 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 +15 -3
- 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.js +1 -1
- 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/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/schema.js +7 -7
- 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/index.d.ts +1 -0
- package/dist/authorization/index.d.ts.map +1 -1
- package/dist/authorization/index.js +2 -0
- package/dist/authorization/index.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/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 +20 -0
- package/dist/branch-metadata.d.ts.map +1 -1
- package/dist/branch-metadata.js +21 -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 +154 -13
- package/dist/cli/generate-ai-content.js +54 -10
- package/dist/cli/init.js +72 -0
- 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/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/editor/BranchManager.d.ts +9 -1
- package/dist/editor/BranchManager.d.ts.map +1 -1
- package/dist/editor/BranchManager.js +20 -9
- 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 +12 -3
- package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
- package/dist/editor/hooks/useBranchManager.js +16 -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/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 +10 -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/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 +41 -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 +38 -0
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +328 -25
- 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
|
@@ -8,11 +8,35 @@ import { getBranchMetadataFileManager, BranchMetadataFileManager, buildMergedBra
|
|
|
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 { sanitizeBranchName } from '../paths/branch.js';
|
|
12
|
-
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
11
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
12
|
+
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
13
|
+
import { writeWorkerStatus } from './worker-status.js';
|
|
13
14
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
14
15
|
const DEFAULT_MAX_RETRIES = 3;
|
|
15
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
|
+
}
|
|
16
40
|
/**
|
|
17
41
|
* An error inherent to the task itself (malformed payload, unknown action):
|
|
18
42
|
* retrying can never succeed, so the task should fail fast instead of
|
|
@@ -144,9 +168,25 @@ export class CmsWorker {
|
|
|
144
168
|
this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
145
169
|
this.contentRoot = config.contentRoot ?? 'content';
|
|
146
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
|
+
}
|
|
147
186
|
async start() {
|
|
148
187
|
this.running = true;
|
|
149
188
|
console.log('CMS Worker starting...');
|
|
189
|
+
this.ensureStatusReport();
|
|
150
190
|
// Acquire lock to prevent concurrent workers
|
|
151
191
|
await this.acquireLock();
|
|
152
192
|
// Everything below runs while holding the cross-host worker lock. A
|
|
@@ -177,6 +217,25 @@ export class CmsWorker {
|
|
|
177
217
|
await Promise.allSettled(initialTasks);
|
|
178
218
|
}
|
|
179
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
|
+
}
|
|
180
239
|
await this.releaseLock();
|
|
181
240
|
throw err;
|
|
182
241
|
}
|
|
@@ -398,6 +457,11 @@ export class CmsWorker {
|
|
|
398
457
|
catch (err) {
|
|
399
458
|
const message = getErrorMessage(err);
|
|
400
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);
|
|
401
465
|
// DEP-L1: only transient failures (network, 429/5xx, timeouts) are
|
|
402
466
|
// worth retrying; permanent ones (malformed payload, other 4xx) would
|
|
403
467
|
// just burn the retry budget on an identical doomed request.
|
|
@@ -405,12 +469,12 @@ export class CmsWorker {
|
|
|
405
469
|
const retryCount = task.retryCount ?? 0;
|
|
406
470
|
const maxRetries = task.maxRetries ?? this.maxRetries;
|
|
407
471
|
if (!permanent && retryCount < maxRetries) {
|
|
408
|
-
await retryTask(this.taskDir, task.id,
|
|
472
|
+
await retryTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
409
473
|
console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
|
|
410
474
|
}
|
|
411
475
|
else {
|
|
412
|
-
await failTask(this.taskDir, task.id,
|
|
413
|
-
await this.updateBranchMetadataOnFailure(task,
|
|
476
|
+
await failTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
477
|
+
await this.updateBranchMetadataOnFailure(task, persistedMessage);
|
|
414
478
|
console.error(permanent
|
|
415
479
|
? ' Permanently failed (non-retryable error)'
|
|
416
480
|
: ` Permanently failed after ${maxRetries} retries`);
|
|
@@ -418,6 +482,15 @@ export class CmsWorker {
|
|
|
418
482
|
}
|
|
419
483
|
processed++;
|
|
420
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
|
+
}
|
|
421
494
|
}
|
|
422
495
|
/**
|
|
423
496
|
* Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
|
|
@@ -493,13 +566,23 @@ export class CmsWorker {
|
|
|
493
566
|
// direct-API path) so the list->tiebreak->update/create logic and
|
|
494
567
|
// the draft->ready conversion live in one place.
|
|
495
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
|
+
}
|
|
496
579
|
await this.pushBranchToGitHub(branch);
|
|
497
580
|
const result = await createOrUpdatePullRequest({
|
|
498
581
|
octokit: this.octokit,
|
|
499
582
|
owner: this.config.githubOwner,
|
|
500
583
|
repo: this.config.githubRepo,
|
|
501
584
|
head: branch,
|
|
502
|
-
base
|
|
585
|
+
base,
|
|
503
586
|
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
504
587
|
body: optionalString(payload, 'body', ''),
|
|
505
588
|
// Content submits (api/github-sync.ts) set this; settings-branch
|
|
@@ -660,6 +743,7 @@ export class CmsWorker {
|
|
|
660
743
|
if (!this.running)
|
|
661
744
|
return;
|
|
662
745
|
console.log('Syncing git...');
|
|
746
|
+
const cycleStartedAt = Date.now();
|
|
663
747
|
const git = simpleGit({
|
|
664
748
|
baseDir: this.remoteGitPath,
|
|
665
749
|
// DEP-H1: a hung fetch/push would stall the sync loop forever
|
|
@@ -667,18 +751,109 @@ export class CmsWorker {
|
|
|
667
751
|
// is inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
668
752
|
timeout: { block: this.taskTimeoutMs },
|
|
669
753
|
});
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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;
|
|
682
857
|
}
|
|
683
858
|
/**
|
|
684
859
|
* Fast-forward the base branch's own working-tree clone
|
|
@@ -756,10 +931,18 @@ export class CmsWorker {
|
|
|
756
931
|
// Use rev-list instead of status.behind for the same reason as the
|
|
757
932
|
// rebase loop below: status.behind only works with an upstream
|
|
758
933
|
// tracking branch configured, which isn't guaranteed here.
|
|
759
|
-
|
|
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);
|
|
760
943
|
if (behindCount > 0) {
|
|
761
944
|
try {
|
|
762
|
-
await baseGit.merge(['--ff-only',
|
|
945
|
+
await baseGit.merge(['--ff-only', fetchedTip]);
|
|
763
946
|
}
|
|
764
947
|
catch (err) {
|
|
765
948
|
console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
|
|
@@ -847,13 +1030,74 @@ export class CmsWorker {
|
|
|
847
1030
|
clearTimeout(timer);
|
|
848
1031
|
}
|
|
849
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
|
+
}
|
|
1087
|
+
}
|
|
850
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 = [];
|
|
851
1095
|
let branchDirs;
|
|
852
1096
|
try {
|
|
853
1097
|
branchDirs = await fs.readdir(this.contentBranchesPath);
|
|
854
1098
|
}
|
|
855
1099
|
catch {
|
|
856
|
-
return;
|
|
1100
|
+
return { rebased, skippedDirty, failed };
|
|
857
1101
|
}
|
|
858
1102
|
for (const branchDir of branchDirs) {
|
|
859
1103
|
// Known structural entries under content-branches/, not branch
|
|
@@ -922,13 +1166,20 @@ export class CmsWorker {
|
|
|
922
1166
|
const dirtyCheck = await branchGit.status();
|
|
923
1167
|
if (dirtyCheck.files.length > 0) {
|
|
924
1168
|
console.log(` Skipping ${branchDir}: has uncommitted changes`);
|
|
1169
|
+
skippedDirty.push(branchDir);
|
|
925
1170
|
continue;
|
|
926
1171
|
}
|
|
927
1172
|
await branchGit.fetch('origin', this.baseBranch);
|
|
928
1173
|
// Use rev-list instead of status.behind — status.behind only works when the
|
|
929
1174
|
// branch has an upstream tracking branch configured, which isn't guaranteed
|
|
930
1175
|
// (checkoutBranch fallback paths create branches without --track).
|
|
931
|
-
|
|
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);
|
|
932
1183
|
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
933
1184
|
if (behindCount === 0) {
|
|
934
1185
|
// Already in sync. This is the overwhelmingly common outcome per
|
|
@@ -945,8 +1196,13 @@ export class CmsWorker {
|
|
|
945
1196
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
946
1197
|
const conflictStatus = currentMeta?.branch.conflictStatus;
|
|
947
1198
|
const conflictFiles = currentMeta?.branch.conflictFiles;
|
|
948
|
-
const
|
|
1199
|
+
const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
|
|
949
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;
|
|
950
1206
|
if (alreadyClean) {
|
|
951
1207
|
continue;
|
|
952
1208
|
}
|
|
@@ -955,6 +1211,7 @@ export class CmsWorker {
|
|
|
955
1211
|
name: branchDir,
|
|
956
1212
|
conflictStatus: 'clean',
|
|
957
1213
|
conflictFiles: [],
|
|
1214
|
+
rebaseFailure: undefined,
|
|
958
1215
|
},
|
|
959
1216
|
});
|
|
960
1217
|
continue;
|
|
@@ -965,11 +1222,16 @@ export class CmsWorker {
|
|
|
965
1222
|
const conflictedFiles = [];
|
|
966
1223
|
let nextAction = 'start';
|
|
967
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;
|
|
968
1228
|
const MAX_ROUNDS = 50; // safety limit against infinite loops
|
|
969
1229
|
for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
|
|
970
1230
|
try {
|
|
971
1231
|
if (nextAction === 'start') {
|
|
972
|
-
|
|
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]);
|
|
973
1235
|
}
|
|
974
1236
|
else if (nextAction === 'continue') {
|
|
975
1237
|
await branchGit.rebase(['--continue']);
|
|
@@ -1006,6 +1268,7 @@ export class CmsWorker {
|
|
|
1006
1268
|
// state. Previous metadata (possibly stale) is preserved until the
|
|
1007
1269
|
// next successful rebase cycle corrects it.
|
|
1008
1270
|
console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
1271
|
+
failureReason = msg || 'Unknown error';
|
|
1009
1272
|
await branchGit.rebase(['--abort']).catch(() => { });
|
|
1010
1273
|
break;
|
|
1011
1274
|
}
|
|
@@ -1013,8 +1276,30 @@ export class CmsWorker {
|
|
|
1013
1276
|
}
|
|
1014
1277
|
}
|
|
1015
1278
|
if (!completed) {
|
|
1016
|
-
|
|
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`);
|
|
1017
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);
|
|
1018
1303
|
continue;
|
|
1019
1304
|
}
|
|
1020
1305
|
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
@@ -1055,14 +1340,32 @@ export class CmsWorker {
|
|
|
1055
1340
|
name: branchDir,
|
|
1056
1341
|
conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
|
|
1057
1342
|
conflictFiles: conflictIdsDeduped,
|
|
1343
|
+
// PR-W2: the cycle completed successfully -- clear any prior
|
|
1344
|
+
// failure record regardless of conflict outcome.
|
|
1345
|
+
rebaseFailure: undefined,
|
|
1058
1346
|
},
|
|
1059
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);
|
|
1060
1353
|
}
|
|
1061
1354
|
catch (err) {
|
|
1062
1355
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1063
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);
|
|
1064
1366
|
}
|
|
1065
1367
|
}
|
|
1368
|
+
return { rebased, skippedDirty, failed };
|
|
1066
1369
|
}
|
|
1067
1370
|
async refreshAuthCache() {
|
|
1068
1371
|
if (!this.running || !this.config.refreshAuthCache)
|