canopycms 0.0.61-int.75 → 0.0.62
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/ai/handler.js +1 -1
- package/dist/ai/handler.js.map +1 -1
- package/dist/ai/json-to-markdown.js +1 -2
- package/dist/ai/json-to-markdown.js.map +1 -1
- package/dist/api/__test__/mock-client.d.ts +5 -1
- package/dist/api/__test__/mock-client.d.ts.map +1 -1
- package/dist/api/__test__/mock-client.js +9 -2
- package/dist/api/__test__/mock-client.js.map +1 -1
- package/dist/api/admin-branch-health.d.ts +36 -1
- package/dist/api/admin-branch-health.d.ts.map +1 -1
- package/dist/api/admin-branch-health.js +176 -2
- package/dist/api/admin-branch-health.js.map +1 -1
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/admin.d.ts.map +1 -1
- package/dist/api/admin.js.map +1 -1
- package/dist/api/assets.d.ts.map +1 -1
- package/dist/api/assets.js +36 -4
- package/dist/api/assets.js.map +1 -1
- package/dist/api/branch-status.d.ts.map +1 -1
- package/dist/api/branch-status.js +56 -0
- package/dist/api/branch-status.js.map +1 -1
- package/dist/api/branch-withdraw.d.ts.map +1 -1
- package/dist/api/branch-withdraw.js +14 -3
- package/dist/api/branch-withdraw.js.map +1 -1
- package/dist/api/branch.d.ts +31 -2
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +268 -15
- package/dist/api/branch.js.map +1 -1
- package/dist/api/client.d.ts +5 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +6 -0
- package/dist/api/client.js.map +1 -1
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +11 -5
- package/dist/api/content.d.ts.map +1 -1
- package/dist/api/content.js +91 -10
- package/dist/api/content.js.map +1 -1
- package/dist/api/entries.d.ts +10 -0
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +51 -24
- package/dist/api/entries.js.map +1 -1
- package/dist/api/guards.d.ts.map +1 -1
- package/dist/api/guards.js +12 -5
- package/dist/api/guards.js.map +1 -1
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/permissions.d.ts.map +1 -1
- package/dist/api/permissions.js +51 -3
- package/dist/api/permissions.js.map +1 -1
- package/dist/api/reference-options.d.ts.map +1 -1
- package/dist/api/reference-options.js +10 -17
- package/dist/api/reference-options.js.map +1 -1
- package/dist/api/resolve-references.d.ts.map +1 -1
- package/dist/api/resolve-references.js +3 -1
- package/dist/api/resolve-references.js.map +1 -1
- package/dist/api/schema.d.ts +4 -4
- package/dist/api/schema.d.ts.map +1 -1
- package/dist/api/schema.js +16 -15
- package/dist/api/schema.js.map +1 -1
- package/dist/api/settings-helpers.d.ts.map +1 -1
- package/dist/api/settings-helpers.js +6 -5
- package/dist/api/settings-helpers.js.map +1 -1
- package/dist/assets/pipeline.d.ts +11 -2
- package/dist/assets/pipeline.d.ts.map +1 -1
- package/dist/assets/pipeline.js +114 -2
- package/dist/assets/pipeline.js.map +1 -1
- package/dist/assets/transform-directives.d.ts +18 -0
- package/dist/assets/transform-directives.d.ts.map +1 -1
- package/dist/assets/transform-directives.js +18 -0
- package/dist/assets/transform-directives.js.map +1 -1
- package/dist/assets/transform.d.ts.map +1 -1
- package/dist/assets/transform.js +20 -16
- package/dist/assets/transform.js.map +1 -1
- package/dist/auth/types.d.ts +29 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/authorization/index.d.ts +1 -1
- package/dist/authorization/index.d.ts.map +1 -1
- package/dist/authorization/index.js +1 -1
- package/dist/authorization/index.js.map +1 -1
- package/dist/authorization/protected-branch.d.ts +75 -0
- package/dist/authorization/protected-branch.d.ts.map +1 -1
- package/dist/authorization/protected-branch.js +39 -0
- package/dist/authorization/protected-branch.js.map +1 -1
- package/dist/branch-health.d.ts +13 -0
- package/dist/branch-health.d.ts.map +1 -1
- package/dist/branch-health.js +23 -0
- package/dist/branch-health.js.map +1 -1
- package/dist/branch-registry.d.ts.map +1 -1
- package/dist/branch-registry.js +5 -1
- package/dist/branch-registry.js.map +1 -1
- package/dist/branch-schema-cache.d.ts.map +1 -1
- package/dist/branch-schema-cache.js +9 -0
- package/dist/branch-schema-cache.js.map +1 -1
- package/dist/build/generate-ai-content.js +1 -1
- package/dist/build/generate-ai-content.js.map +1 -1
- package/dist/cli/cli.js +2215 -1280
- package/dist/cli/generate-ai-content.js +1158 -585
- package/dist/cli/init.d.ts +8 -2
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +459 -152
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/project-detect.d.ts +89 -0
- package/dist/cli/project-detect.d.ts.map +1 -0
- package/dist/cli/project-detect.js +240 -0
- package/dist/cli/project-detect.js.map +1 -0
- package/dist/cli/template-files/Dockerfile.cms.template +30 -7
- package/dist/cli/template-files/canopycms.config.ts.template +7 -0
- package/dist/cli/template-files/cdk-app.ts.template +82 -0
- package/dist/cli/template-files/cdk.json.template +9 -0
- package/dist/cli/template-files/cms-stack.ts.template +130 -0
- package/dist/cli/template-files/deploy-cms.yml.template +125 -13
- package/dist/cli/template-files/dockerignore.template +2 -1
- package/dist/cli/templates.d.ts +26 -2
- package/dist/cli/templates.d.ts.map +1 -1
- package/dist/cli/templates.js +36 -4
- package/dist/cli/templates.js.map +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/config/schemas/config.d.ts +2 -3
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/config.js +21 -1
- package/dist/config/schemas/config.js.map +1 -1
- package/dist/config/schemas/field.d.ts +15 -15
- package/dist/config/types.d.ts +2 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +0 -1
- package/dist/config/types.js.map +1 -1
- package/dist/config/validation.d.ts +18 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +36 -12
- package/dist/config/validation.js.map +1 -1
- package/dist/content-id-index.d.ts +97 -5
- package/dist/content-id-index.d.ts.map +1 -1
- package/dist/content-id-index.js +170 -18
- package/dist/content-id-index.js.map +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-reader.js.map +1 -1
- package/dist/content-store.d.ts +92 -7
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +445 -259
- package/dist/content-store.js.map +1 -1
- package/dist/dev-content-watcher.d.ts.map +1 -1
- package/dist/dev-content-watcher.js +4 -1
- package/dist/dev-content-watcher.js.map +1 -1
- package/dist/editor/BranchManager.d.ts +15 -0
- package/dist/editor/BranchManager.d.ts.map +1 -1
- package/dist/editor/BranchManager.js +61 -15
- package/dist/editor/BranchManager.js.map +1 -1
- package/dist/editor/CanopyEditor.d.ts.map +1 -1
- package/dist/editor/CanopyEditor.js +3 -3
- package/dist/editor/CanopyEditor.js.map +1 -1
- package/dist/editor/CanopyEditorPage.d.ts +9 -1
- package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
- package/dist/editor/CanopyEditorPage.js +9 -2
- package/dist/editor/CanopyEditorPage.js.map +1 -1
- package/dist/editor/Editor.d.ts +9 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +156 -60
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/FormRenderer.d.ts.map +1 -1
- package/dist/editor/FormRenderer.js +12 -0
- package/dist/editor/FormRenderer.js.map +1 -1
- package/dist/editor/admin/SystemHealthPanel.d.ts.map +1 -1
- package/dist/editor/admin/SystemHealthPanel.js +14 -9
- package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
- package/dist/editor/components/EditorHeader.d.ts +6 -0
- package/dist/editor/components/EditorHeader.d.ts.map +1 -1
- package/dist/editor/components/EditorHeader.js +76 -23
- package/dist/editor/components/EditorHeader.js.map +1 -1
- package/dist/editor/components/EntryCreateModal.d.ts +9 -1
- package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
- package/dist/editor/components/EntryCreateModal.js +25 -7
- package/dist/editor/components/EntryCreateModal.js.map +1 -1
- package/dist/editor/context/SWRProvider.d.ts +31 -0
- package/dist/editor/context/SWRProvider.d.ts.map +1 -0
- package/dist/editor/context/SWRProvider.js +12 -0
- package/dist/editor/context/SWRProvider.js.map +1 -0
- package/dist/editor/context/index.d.ts +3 -0
- package/dist/editor/context/index.d.ts.map +1 -1
- package/dist/editor/context/index.js +3 -0
- package/dist/editor/context/index.js.map +1 -1
- package/dist/editor/editor-utils.d.ts.map +1 -1
- package/dist/editor/editor-utils.js +8 -2
- package/dist/editor/editor-utils.js.map +1 -1
- package/dist/editor/fields/DateTimeField.d.ts +46 -0
- package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
- package/dist/editor/fields/DateTimeField.js +66 -0
- package/dist/editor/fields/DateTimeField.js.map +1 -0
- package/dist/editor/fields/ImageField.d.ts.map +1 -1
- package/dist/editor/fields/ImageField.js +27 -3
- package/dist/editor/fields/ImageField.js.map +1 -1
- package/dist/editor/fields/NumberField.d.ts +34 -0
- package/dist/editor/fields/NumberField.d.ts.map +1 -0
- package/dist/editor/fields/NumberField.js +53 -0
- package/dist/editor/fields/NumberField.js.map +1 -0
- package/dist/editor/fields/NumberListField.d.ts +25 -0
- package/dist/editor/fields/NumberListField.d.ts.map +1 -0
- package/dist/editor/fields/NumberListField.js +53 -0
- package/dist/editor/fields/NumberListField.js.map +1 -0
- package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
- package/dist/editor/fields/ReferenceField.js +52 -31
- package/dist/editor/fields/ReferenceField.js.map +1 -1
- package/dist/editor/hooks/__test__/test-utils.d.ts +38 -3
- package/dist/editor/hooks/__test__/test-utils.d.ts.map +1 -1
- package/dist/editor/hooks/__test__/test-utils.js +50 -4
- package/dist/editor/hooks/__test__/test-utils.js.map +1 -1
- package/dist/editor/hooks/index.d.ts +4 -1
- package/dist/editor/hooks/index.d.ts.map +1 -1
- package/dist/editor/hooks/index.js +4 -1
- package/dist/editor/hooks/index.js.map +1 -1
- package/dist/editor/hooks/useBranchManager.d.ts +21 -1
- package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
- package/dist/editor/hooks/useBranchManager.js +183 -58
- package/dist/editor/hooks/useBranchManager.js.map +1 -1
- package/dist/editor/hooks/useBranchesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchesData.d.ts.map +1 -0
- package/dist/editor/hooks/useBranchesData.js +69 -0
- package/dist/editor/hooks/useBranchesData.js.map +1 -0
- package/dist/editor/hooks/useCommentSystem.d.ts.map +1 -1
- package/dist/editor/hooks/useCommentSystem.js +24 -22
- package/dist/editor/hooks/useCommentSystem.js.map +1 -1
- package/dist/editor/hooks/useCommentsData.d.ts +29 -0
- package/dist/editor/hooks/useCommentsData.d.ts.map +1 -0
- package/dist/editor/hooks/useCommentsData.js +40 -0
- package/dist/editor/hooks/useCommentsData.js.map +1 -0
- package/dist/editor/hooks/useDraftManager.d.ts +8 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +290 -36
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useEntriesData.d.ts +35 -0
- package/dist/editor/hooks/useEntriesData.d.ts.map +1 -0
- package/dist/editor/hooks/useEntriesData.js +96 -0
- package/dist/editor/hooks/useEntriesData.js.map +1 -0
- package/dist/editor/hooks/useEntryManager.d.ts +29 -15
- package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
- package/dist/editor/hooks/useEntryManager.js +284 -119
- package/dist/editor/hooks/useEntryManager.js.map +1 -1
- package/dist/editor/hooks/usePermissionManager.d.ts +2 -2
- package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
- package/dist/editor/hooks/usePermissionManager.js +11 -9
- package/dist/editor/hooks/usePermissionManager.js.map +1 -1
- package/dist/editor/hooks/useReferenceResolution.d.ts.map +1 -1
- package/dist/editor/hooks/useReferenceResolution.js +24 -1
- package/dist/editor/hooks/useReferenceResolution.js.map +1 -1
- package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -1
- package/dist/editor/media/MediaLibraryBody.js +12 -2
- package/dist/editor/media/MediaLibraryBody.js.map +1 -1
- package/dist/editor/permission-manager/GroupSelector.d.ts.map +1 -1
- package/dist/editor/permission-manager/GroupSelector.js +7 -2
- package/dist/editor/permission-manager/GroupSelector.js.map +1 -1
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +3 -3
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts.map +1 -1
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +1 -0
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js.map +1 -1
- package/dist/editor/permission-manager/types.d.ts +6 -4
- package/dist/editor/permission-manager/types.d.ts.map +1 -1
- package/dist/editor/test-setup.js +8 -0
- package/dist/editor/test-setup.js.map +1 -1
- package/dist/editor/theme.d.ts +8 -8
- package/dist/git-manager.d.ts +175 -14
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +354 -39
- package/dist/git-manager.js.map +1 -1
- package/dist/github-service.d.ts.map +1 -1
- package/dist/github-service.js +13 -7
- package/dist/github-service.js.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/handler.js +78 -29
- package/dist/http/handler.js.map +1 -1
- package/dist/http/router.d.ts.map +1 -1
- package/dist/http/router.js +40 -3
- package/dist/http/router.js.map +1 -1
- package/dist/operating-mode/client-unsafe-strategy.d.ts.map +1 -1
- package/dist/operating-mode/client-unsafe-strategy.js +7 -8
- package/dist/operating-mode/client-unsafe-strategy.js.map +1 -1
- package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
- package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
- package/dist/operating-mode/deployment-name-fixtures.js +55 -0
- package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
- package/dist/operating-mode/deployment-name.d.ts +37 -0
- package/dist/operating-mode/deployment-name.d.ts.map +1 -0
- package/dist/operating-mode/deployment-name.js +86 -0
- package/dist/operating-mode/deployment-name.js.map +1 -0
- package/dist/operating-mode/index.d.ts +6 -1
- package/dist/operating-mode/index.d.ts.map +1 -1
- package/dist/operating-mode/index.js +10 -1
- package/dist/operating-mode/index.js.map +1 -1
- package/dist/operating-mode/mode-env.d.ts +58 -0
- package/dist/operating-mode/mode-env.d.ts.map +1 -0
- package/dist/operating-mode/mode-env.js +97 -0
- package/dist/operating-mode/mode-env.js.map +1 -0
- package/dist/operating-mode/types.d.ts +13 -3
- package/dist/operating-mode/types.d.ts.map +1 -1
- package/dist/paths/branch-name.d.ts +38 -0
- package/dist/paths/branch-name.d.ts.map +1 -1
- package/dist/paths/branch-name.js +46 -0
- package/dist/paths/branch-name.js.map +1 -1
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.d.ts.map +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/index.js.map +1 -1
- package/dist/reference-resolver.d.ts +6 -2
- package/dist/reference-resolver.d.ts.map +1 -1
- package/dist/reference-resolver.js +8 -1
- package/dist/reference-resolver.js.map +1 -1
- package/dist/resolve-canopy-user.d.ts +34 -0
- package/dist/resolve-canopy-user.d.ts.map +1 -0
- package/dist/resolve-canopy-user.js +55 -0
- package/dist/resolve-canopy-user.js.map +1 -0
- package/dist/schema/meta-loader.d.ts.map +1 -1
- package/dist/schema/meta-loader.js +10 -2
- package/dist/schema/meta-loader.js.map +1 -1
- package/dist/schema/schema-store.d.ts +64 -4
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +113 -30
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -0
- package/dist/server.js.map +1 -1
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +40 -6
- package/dist/services.js.map +1 -1
- package/dist/settings-workspace.d.ts +27 -2
- package/dist/settings-workspace.d.ts.map +1 -1
- package/dist/settings-workspace.js +142 -58
- package/dist/settings-workspace.js.map +1 -1
- package/dist/types.d.ts +73 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/content-write-lock.d.ts +127 -0
- package/dist/utils/content-write-lock.d.ts.map +1 -0
- package/dist/utils/content-write-lock.js +170 -0
- package/dist/utils/content-write-lock.js.map +1 -0
- package/dist/utils/debug.d.ts +11 -0
- package/dist/utils/debug.d.ts.map +1 -1
- package/dist/utils/debug.js +12 -1
- package/dist/utils/debug.js.map +1 -1
- package/dist/utils/error.d.ts +1 -1
- package/dist/utils/error.js +1 -1
- package/dist/utils/git.d.ts +54 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +91 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/logger.d.ts +71 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +85 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/sanitize-href.d.ts +28 -3
- package/dist/utils/sanitize-href.d.ts.map +1 -1
- package/dist/utils/sanitize-href.js +64 -6
- package/dist/utils/sanitize-href.js.map +1 -1
- package/dist/validation/entry-link-validator.d.ts +1 -1
- package/dist/validation/entry-link-validator.js +3 -3
- package/dist/validation/entry-link-validator.js.map +1 -1
- package/dist/validation/entry-type-reference-validator.d.ts +33 -0
- package/dist/validation/entry-type-reference-validator.d.ts.map +1 -0
- package/dist/validation/entry-type-reference-validator.js +110 -0
- package/dist/validation/entry-type-reference-validator.js.map +1 -0
- package/dist/validation/entry-validator.js +1 -1
- package/dist/validation/entry-validator.js.map +1 -1
- package/dist/worker/cms-worker.d.ts +245 -3
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +1084 -253
- package/dist/worker/cms-worker.js.map +1 -1
- package/dist/worker/log.d.ts +61 -0
- package/dist/worker/log.d.ts.map +1 -0
- package/dist/worker/log.js +74 -0
- package/dist/worker/log.js.map +1 -0
- package/package.json +4 -2
|
@@ -2,15 +2,27 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { simpleGit } from 'simple-git';
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
|
-
import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
|
|
5
|
+
import { enqueueTask, listTasks, dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
|
|
6
6
|
import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
|
|
7
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 { sanitizeBranchName } from '../paths/branch-name.js';
|
|
11
|
+
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
12
|
+
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
13
|
+
import { GITHUB_TRACKING_REF_PREFIX, gitChildEnv, gitNetworkChildEnv } from '../git-manager.js';
|
|
14
|
+
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
15
|
+
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
12
16
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
17
|
+
import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
|
|
13
18
|
import { writeWorkerStatus } from './worker-status.js';
|
|
19
|
+
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
20
|
+
// Re-exported so the AWS entrypoint (packages/canopycms-cdk/worker/index.ts)
|
|
21
|
+
// can prefix its own startup lines through the same helpers without adding a
|
|
22
|
+
// new package entrypoint - `canopycms/worker/cms-worker` already exists. Every
|
|
23
|
+
// line in worker.log must carry the timestamp prefix or it gets folded into
|
|
24
|
+
// the previous CloudWatch event; see ./log.ts.
|
|
25
|
+
export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from './log.js';
|
|
14
26
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
15
27
|
const DEFAULT_MAX_RETRIES = 3;
|
|
16
28
|
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
@@ -183,9 +195,45 @@ export class CmsWorker {
|
|
|
183
195
|
}
|
|
184
196
|
return this.statusReport;
|
|
185
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Resolve this deployment's settings branch, throwing if the infra-stamped
|
|
200
|
+
* deployment name is not a valid git ref component.
|
|
201
|
+
*
|
|
202
|
+
* Routed through the shared resolver, not a local `?? 'prod'`: it is the
|
|
203
|
+
* single definition of the env > config > mode-default precedence the Lambda
|
|
204
|
+
* already follows, and the only place the resolved value is validated.
|
|
205
|
+
* Without it the worker could silently own a different settings branch than
|
|
206
|
+
* the Lambda writing to the same workspace -- and pushSettingsBranches would
|
|
207
|
+
* then report the real branch as foreign and never push it.
|
|
208
|
+
*
|
|
209
|
+
* DEFERRED out of the constructor deliberately, and this is the whole point
|
|
210
|
+
* of the method existing. Resolving there made the throw land during `new
|
|
211
|
+
* CmsWorker(...)` (canopycms-cdk/worker/index.ts constructs before it calls
|
|
212
|
+
* start()), which is BEFORE the only code that writes `lastFatalError` --
|
|
213
|
+
* start()'s catch. The result was billed as "a loud startup exit" but was
|
|
214
|
+
* nothing of the kind: with systemd `Type=simple` + `Restart=always` and no
|
|
215
|
+
* cfn-signal, an invalid deployment name produced an invisible ~5s
|
|
216
|
+
* crash-loop that `cdk deploy` reported as success while the admin panel
|
|
217
|
+
* showed the worker as 'absent' with no fatal error to explain it. Resolving
|
|
218
|
+
* inside start()'s try block instead means the same throw is recorded to
|
|
219
|
+
* worker-status.json and surfaces in the admin panel.
|
|
220
|
+
*
|
|
221
|
+
* Lazy rather than start()-only so the value is still available to unit tests
|
|
222
|
+
* that drive pushSettingsBranches() directly without calling start() -- the
|
|
223
|
+
* same reason ensureStatusReport() above is shaped this way. Idempotent: the
|
|
224
|
+
* resolver is pure, so a later call returns the identical string.
|
|
225
|
+
*/
|
|
226
|
+
ensureSettingsBranch() {
|
|
227
|
+
if (this.settingsBranchResolved === undefined) {
|
|
228
|
+
this.settingsBranchResolved =
|
|
229
|
+
this.config.settingsBranch ??
|
|
230
|
+
`${RESERVED_SETTINGS_BRANCH_PREFIX}${resolveDeploymentName({ deploymentName: this.config.deploymentName }, 'prod')}`;
|
|
231
|
+
}
|
|
232
|
+
return this.settingsBranchResolved;
|
|
233
|
+
}
|
|
186
234
|
async start() {
|
|
187
235
|
this.running = true;
|
|
188
|
-
|
|
236
|
+
workerLog('CMS Worker starting...');
|
|
189
237
|
this.ensureStatusReport();
|
|
190
238
|
// Acquire lock to prevent concurrent workers
|
|
191
239
|
await this.acquireLock();
|
|
@@ -202,12 +250,24 @@ export class CmsWorker {
|
|
|
202
250
|
// clones elsewhere) — acquiring the lock first is what already
|
|
203
251
|
// serializes that.
|
|
204
252
|
try {
|
|
253
|
+
// FIRST inside the try, before any I/O: an infra-stamped deployment name
|
|
254
|
+
// that is not a valid git ref component throws here, where the catch
|
|
255
|
+
// below records it to worker-status.json. Resolving it in the
|
|
256
|
+
// constructor (as this used to) put the throw outside every
|
|
257
|
+
// status-writing path -- see ensureSettingsBranch()'s doc comment.
|
|
258
|
+
this.ensureSettingsBranch();
|
|
205
259
|
// Ensure remote.git exists (init bare repo if first run)
|
|
206
260
|
await this.ensureRemoteGit();
|
|
207
|
-
// Recover any orphaned tasks from a previous crash
|
|
208
|
-
|
|
261
|
+
// Recover any orphaned tasks from a previous crash, immediately rather
|
|
262
|
+
// than waiting for the first processTaskQueue() poll (taskPollInterval,
|
|
263
|
+
// default 5s) to run it. Not the only call site any more -
|
|
264
|
+
// processTaskQueue() below now repeats this on every cycle; see its
|
|
265
|
+
// doc comment for why a boot-only call is insufficient once the
|
|
266
|
+
// worker's ASG rolls on every `cdk deploy` (CanopyCmsService's
|
|
267
|
+
// UpdatePolicy).
|
|
268
|
+
const recovered = await recoverOrphanedTasks(this.taskDir, this.orphanRecoveryMaxAgeMs(), this.log);
|
|
209
269
|
if (recovered > 0) {
|
|
210
|
-
|
|
270
|
+
workerLog(`Recovered ${recovered} orphaned task(s)`);
|
|
211
271
|
}
|
|
212
272
|
// Run initial sync + cache refresh immediately
|
|
213
273
|
const initialTasks = [this.syncGit()];
|
|
@@ -234,7 +294,7 @@ export class CmsWorker {
|
|
|
234
294
|
await writeWorkerStatus(this.taskDir, report);
|
|
235
295
|
}
|
|
236
296
|
catch (writeErr) {
|
|
237
|
-
|
|
297
|
+
workerLogError('Failed to write worker status on startup failure:', getErrorMessage(writeErr));
|
|
238
298
|
}
|
|
239
299
|
await this.releaseLock();
|
|
240
300
|
throw err;
|
|
@@ -248,11 +308,11 @@ export class CmsWorker {
|
|
|
248
308
|
if (this.config.refreshAuthCache) {
|
|
249
309
|
const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
|
|
250
310
|
this.scheduleLoop(() => this.refreshAuthCache(), cacheInterval);
|
|
251
|
-
|
|
311
|
+
workerLog(` Auth cache refresh: every ${cacheInterval / 1000}s`);
|
|
252
312
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
313
|
+
workerLog('CMS Worker started');
|
|
314
|
+
workerLog(` Task queue poll: every ${taskInterval / 1000}s`);
|
|
315
|
+
workerLog(` Git sync: every ${gitInterval / 1000}s`);
|
|
256
316
|
}
|
|
257
317
|
async stop() {
|
|
258
318
|
this.running = false;
|
|
@@ -270,7 +330,7 @@ export class CmsWorker {
|
|
|
270
330
|
]);
|
|
271
331
|
clearTimeout(drainTimer);
|
|
272
332
|
await this.releaseLock();
|
|
273
|
-
|
|
333
|
+
workerLog('CMS Worker stopped');
|
|
274
334
|
}
|
|
275
335
|
/**
|
|
276
336
|
* Acquire the cross-host worker lock (DEP-C2).
|
|
@@ -307,7 +367,7 @@ export class CmsWorker {
|
|
|
307
367
|
// Our heartbeat could not be maintained (lock deleted or taken
|
|
308
368
|
// over). Another worker may now be consuming the queue — stop
|
|
309
369
|
// processing to preserve the single-consumer invariant.
|
|
310
|
-
|
|
370
|
+
workerLogError('Worker lock compromised, shutting down:', getErrorMessage(err));
|
|
311
371
|
this.releaseLockFn = null; // the lock is already lost; nothing to release
|
|
312
372
|
void this.stop();
|
|
313
373
|
},
|
|
@@ -344,7 +404,7 @@ export class CmsWorker {
|
|
|
344
404
|
const timeout = setTimeout(async () => {
|
|
345
405
|
this.activeTimeouts.delete(timeout);
|
|
346
406
|
const operation = fn().catch((err) => {
|
|
347
|
-
|
|
407
|
+
workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
|
|
348
408
|
});
|
|
349
409
|
this.activeOperations.add(operation);
|
|
350
410
|
operation.finally(() => this.activeOperations.delete(operation));
|
|
@@ -409,7 +469,7 @@ export class CmsWorker {
|
|
|
409
469
|
await this.verifyBaseBranchExists(this.remoteGitPath);
|
|
410
470
|
}
|
|
411
471
|
catch (err) {
|
|
412
|
-
|
|
472
|
+
workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
|
|
413
473
|
// Do NOT auto-delete: an existing remote.git could hold unpushed
|
|
414
474
|
// canopycms-settings-* branches or other state worth preserving.
|
|
415
475
|
// Deletion here is the operator's call, not ours.
|
|
@@ -417,14 +477,14 @@ export class CmsWorker {
|
|
|
417
477
|
}
|
|
418
478
|
return; // Already exists and has the base branch
|
|
419
479
|
}
|
|
420
|
-
|
|
480
|
+
workerLog('Initializing remote.git from GitHub...');
|
|
421
481
|
const git = simpleGit();
|
|
422
482
|
await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
|
|
423
483
|
try {
|
|
424
484
|
await this.verifyBaseBranchExists(this.remoteGitPath);
|
|
425
485
|
}
|
|
426
486
|
catch (err) {
|
|
427
|
-
|
|
487
|
+
workerLogError(`remote.git base branch verification failed after clone: ${getErrorMessage(err)}`);
|
|
428
488
|
// Deleting before throwing is what makes this recoverable: the next
|
|
429
489
|
// start() sees no remote.git and re-clones, instead of being stuck
|
|
430
490
|
// forever behind a poisoned bare repo that fs.stat alone can't detect.
|
|
@@ -434,7 +494,20 @@ export class CmsWorker {
|
|
|
434
494
|
// Remove the origin remote so the token doesn't persist in config
|
|
435
495
|
const bareGit = simpleGit({ baseDir: this.remoteGitPath });
|
|
436
496
|
await bareGit.removeRemote('origin').catch(() => { });
|
|
437
|
-
|
|
497
|
+
workerLog('remote.git initialized');
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Staleness threshold for recoverOrphanedTasks, derived from the
|
|
501
|
+
* configured task timeout rather than fixed: the safety argument for
|
|
502
|
+
* running recovery on every poll cycle is "no legitimately in-flight task
|
|
503
|
+
* can be this old, because executeTaskWithTimeout bounds every attempt by
|
|
504
|
+
* taskTimeoutMs" -- which is only true if this threshold scales with
|
|
505
|
+
* taskTimeoutMs. 2x leaves the same comfortable margin the defaults have
|
|
506
|
+
* (60s timeout vs 5min threshold); the 5-minute floor preserves the
|
|
507
|
+
* long-standing default for a replacement instance's boot window.
|
|
508
|
+
*/
|
|
509
|
+
orphanRecoveryMaxAgeMs() {
|
|
510
|
+
return Math.max(5 * 60_000, this.taskTimeoutMs * 2);
|
|
438
511
|
}
|
|
439
512
|
/**
|
|
440
513
|
* Process queued tasks from Lambda.
|
|
@@ -445,6 +518,32 @@ export class CmsWorker {
|
|
|
445
518
|
async processTaskQueue() {
|
|
446
519
|
if (!this.running)
|
|
447
520
|
return;
|
|
521
|
+
// Recover tasks orphaned in processing/ on EVERY cycle, not only at boot
|
|
522
|
+
// (start()'s call above). Before this fix, recoverOrphanedTasks() ran
|
|
523
|
+
// exactly once, at start() - fine when an instance was replaced rarely
|
|
524
|
+
// (a spot interruption), but CanopyCmsService's worker ASG now rolls on
|
|
525
|
+
// every `cdk deploy` (see its UpdatePolicy), making replacement routine.
|
|
526
|
+
// A replacement instance's user-data (yum install git/unzip/nodejs/
|
|
527
|
+
// efs-utils, mount EFS) takes roughly 2-4 minutes, well under
|
|
528
|
+
// recoverOrphanedTasks()'s 5-minute default staleness threshold - so
|
|
529
|
+
// THAT ONE boot-time call would see the just-orphaned file as "too
|
|
530
|
+
// fresh" and skip it, and nothing rescanned afterward: the task (and its
|
|
531
|
+
// branch's syncStatus) would be wedged forever. Re-checking every poll
|
|
532
|
+
// means the file's age eventually crosses the threshold and it gets
|
|
533
|
+
// recovered without operator intervention.
|
|
534
|
+
//
|
|
535
|
+
// Safe to run this often: executeTaskWithTimeout() guarantees every task
|
|
536
|
+
// THIS process dequeues is completed, failed, or retried (all three
|
|
537
|
+
// remove the processing/ file) within taskTimeoutMs - and the staleness
|
|
538
|
+
// threshold is derived from taskTimeoutMs (orphanRecoveryMaxAgeMs below)
|
|
539
|
+
// precisely so that guarantee holds for ANY configured timeout, not just
|
|
540
|
+
// the 60s default: a fixed 5-minute threshold would have this call steal
|
|
541
|
+
// the worker's own still-in-flight task back to pending whenever an
|
|
542
|
+
// adopter configured taskTimeoutMs above ~5 minutes.
|
|
543
|
+
const recovered = await recoverOrphanedTasks(this.taskDir, this.orphanRecoveryMaxAgeMs(), this.log);
|
|
544
|
+
if (recovered > 0) {
|
|
545
|
+
workerLog(`Recovered ${recovered} orphaned task(s)`);
|
|
546
|
+
}
|
|
448
547
|
let processed = 0;
|
|
449
548
|
let task;
|
|
450
549
|
while (processed < this.maxTasksPerCycle &&
|
|
@@ -456,7 +555,7 @@ export class CmsWorker {
|
|
|
456
555
|
}
|
|
457
556
|
catch (err) {
|
|
458
557
|
const message = getErrorMessage(err);
|
|
459
|
-
|
|
558
|
+
workerLogError(`Task ${task.id} (${task.action}) failed:`, message);
|
|
460
559
|
// [HIGH-1] task.error is persisted (pending/failed task JSON) and
|
|
461
560
|
// served to the browser by the admin panel's Tasks tab -- a push
|
|
462
561
|
// failure's message can embed the bot token via buildGitHubUrl().
|
|
@@ -470,12 +569,12 @@ export class CmsWorker {
|
|
|
470
569
|
const maxRetries = task.maxRetries ?? this.maxRetries;
|
|
471
570
|
if (!permanent && retryCount < maxRetries) {
|
|
472
571
|
await retryTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
473
|
-
|
|
572
|
+
workerLog(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
|
|
474
573
|
}
|
|
475
574
|
else {
|
|
476
575
|
await failTask(this.taskDir, task.id, persistedMessage, this.log);
|
|
477
576
|
await this.updateBranchMetadataOnFailure(task, persistedMessage);
|
|
478
|
-
|
|
577
|
+
workerLogError(permanent
|
|
479
578
|
? ' Permanently failed (non-retryable error)'
|
|
480
579
|
: ` Permanently failed after ${maxRetries} retries`);
|
|
481
580
|
}
|
|
@@ -489,7 +588,7 @@ export class CmsWorker {
|
|
|
489
588
|
if (processed > 0) {
|
|
490
589
|
const report = this.ensureStatusReport();
|
|
491
590
|
report.lastTaskCycleAt = new Date().toISOString();
|
|
492
|
-
await writeWorkerStatus(this.taskDir, report).catch((writeErr) =>
|
|
591
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
493
592
|
}
|
|
494
593
|
}
|
|
495
594
|
/**
|
|
@@ -538,7 +637,7 @@ export class CmsWorker {
|
|
|
538
637
|
body: optionalString(payload, 'body', ''),
|
|
539
638
|
request: { signal },
|
|
540
639
|
});
|
|
541
|
-
|
|
640
|
+
workerLog(`Created PR #${pr.data.number} for ${branch}`);
|
|
542
641
|
return { prUrl: pr.data.html_url, prNumber: pr.data.number };
|
|
543
642
|
}
|
|
544
643
|
case 'push-and-update-pr': {
|
|
@@ -553,7 +652,7 @@ export class CmsWorker {
|
|
|
553
652
|
body: optionalString(payload, 'body', ''),
|
|
554
653
|
request: { signal },
|
|
555
654
|
});
|
|
556
|
-
|
|
655
|
+
workerLog(`Updated PR #${prNumber} for ${branch}`);
|
|
557
656
|
return { prNumber };
|
|
558
657
|
}
|
|
559
658
|
case 'push-and-create-or-update-pr': {
|
|
@@ -590,7 +689,7 @@ export class CmsWorker {
|
|
|
590
689
|
markReadyIfDraft: payload.markReadyIfDraft === true,
|
|
591
690
|
signal,
|
|
592
691
|
});
|
|
593
|
-
|
|
692
|
+
workerLog(result.created
|
|
594
693
|
? `Created PR #${result.number} for ${branch}`
|
|
595
694
|
: `Updated existing PR #${result.number} for ${branch}`);
|
|
596
695
|
return { prUrl: result.url, prNumber: result.number };
|
|
@@ -606,7 +705,7 @@ export class CmsWorker {
|
|
|
606
705
|
request: { signal },
|
|
607
706
|
});
|
|
608
707
|
await this.octokit.graphql(`mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: pr.node_id, request: { signal } });
|
|
609
|
-
|
|
708
|
+
workerLog(`Converted PR #${draftPrNumber} to draft`);
|
|
610
709
|
return { prNumber: draftPrNumber, draft: true };
|
|
611
710
|
}
|
|
612
711
|
case 'close-pr': {
|
|
@@ -642,7 +741,7 @@ export class CmsWorker {
|
|
|
642
741
|
const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
|
|
643
742
|
if (!branch)
|
|
644
743
|
return;
|
|
645
|
-
const branchPath =
|
|
744
|
+
const branchPath = this.branchWorkspacePath(branch);
|
|
646
745
|
try {
|
|
647
746
|
await fs.stat(branchPath);
|
|
648
747
|
}
|
|
@@ -654,6 +753,12 @@ export class CmsWorker {
|
|
|
654
753
|
const updates = {
|
|
655
754
|
name: branch,
|
|
656
755
|
syncStatus: 'synced',
|
|
756
|
+
// save()'s merge only overwrites keys present in this update (see
|
|
757
|
+
// BranchMetadataFileManager.save()'s spread order) -- a prior
|
|
758
|
+
// syncFailureReason from an earlier failed attempt would otherwise
|
|
759
|
+
// survive forever past this successful sync. Explicitly clear it,
|
|
760
|
+
// same pattern as rebaseFailure's PR-W2 M2 clearing elsewhere.
|
|
761
|
+
syncFailureReason: undefined,
|
|
657
762
|
};
|
|
658
763
|
if (result.prUrl)
|
|
659
764
|
updates.pullRequestUrl = result.prUrl;
|
|
@@ -675,18 +780,20 @@ export class CmsWorker {
|
|
|
675
780
|
await meta.save({ branch: updates });
|
|
676
781
|
}
|
|
677
782
|
catch (err) {
|
|
678
|
-
|
|
783
|
+
workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
|
|
679
784
|
}
|
|
680
785
|
}
|
|
681
786
|
/**
|
|
682
787
|
* Update branch metadata after permanent task failure.
|
|
683
|
-
* Sets syncStatus to 'sync-failed'
|
|
788
|
+
* Sets syncStatus to 'sync-failed' and records `error` (already redacted
|
|
789
|
+
* by the caller, processTaskQueue -- see [HIGH-1] there) as
|
|
790
|
+
* syncFailureReason, so the editor can show WHY, not just that it failed.
|
|
684
791
|
*/
|
|
685
|
-
async updateBranchMetadataOnFailure(task,
|
|
792
|
+
async updateBranchMetadataOnFailure(task, error) {
|
|
686
793
|
const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
|
|
687
794
|
if (!branch)
|
|
688
795
|
return;
|
|
689
|
-
const branchPath =
|
|
796
|
+
const branchPath = this.branchWorkspacePath(branch);
|
|
690
797
|
try {
|
|
691
798
|
await fs.stat(branchPath);
|
|
692
799
|
}
|
|
@@ -695,15 +802,222 @@ export class CmsWorker {
|
|
|
695
802
|
}
|
|
696
803
|
try {
|
|
697
804
|
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
698
|
-
await meta.save({
|
|
805
|
+
await meta.save({
|
|
806
|
+
branch: { name: branch, syncStatus: 'sync-failed', syncFailureReason: error },
|
|
807
|
+
});
|
|
699
808
|
}
|
|
700
809
|
catch (err) {
|
|
701
|
-
|
|
810
|
+
workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
|
|
702
811
|
}
|
|
703
812
|
}
|
|
704
813
|
buildGitHubUrl() {
|
|
705
814
|
return `https://x-access-token:${this.config.githubToken}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
|
|
706
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* The workspace directory for a branch named by its GIT REF name -- the
|
|
818
|
+
* form task payloads carry (`context.branch.name`), not the directory form.
|
|
819
|
+
*
|
|
820
|
+
* These differ for any name outside `[A-Za-z0-9._-]`, `/` being the obvious
|
|
821
|
+
* one: workspaces are provisioned under `sanitizeBranchName(...)` (see
|
|
822
|
+
* paths/branch.ts's `resolveBranchPaths`), so `feature/x` lives in
|
|
823
|
+
* `feature-x`. Joining the raw name instead silently addresses a directory
|
|
824
|
+
* that does not exist -- which for the metadata writers below meant the
|
|
825
|
+
* update was quietly dropped, and for the leased push meant the
|
|
826
|
+
* history-rewrite marker read as absent and the push went out unleased,
|
|
827
|
+
* wedging exactly the branch this workstream exists to unwedge.
|
|
828
|
+
*
|
|
829
|
+
* `name` inside the metadata itself stays the raw ref name; only the path
|
|
830
|
+
* is sanitized.
|
|
831
|
+
*/
|
|
832
|
+
branchWorkspacePath(branchRefName) {
|
|
833
|
+
return path.join(this.contentBranchesPath, sanitizeBranchName(branchRefName));
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* What `remote.git` currently holds for `branchRef`, or null when this
|
|
837
|
+
* branch was never published there (never submitted) or the ref is
|
|
838
|
+
* unreadable.
|
|
839
|
+
*
|
|
840
|
+
* Uses the same explicit `--git-dir` shape as verifyBaseBranchExists()
|
|
841
|
+
* above: reading a bare repo that way does not depend on
|
|
842
|
+
* `safe.bareRepository` being permissive, which is why prod code takes
|
|
843
|
+
* this route rather than the config override the test-only `openBareRepo`
|
|
844
|
+
* helper uses.
|
|
845
|
+
*/
|
|
846
|
+
async readPublishedSha(branchRef) {
|
|
847
|
+
try {
|
|
848
|
+
const out = await simpleGit().raw([
|
|
849
|
+
'--git-dir',
|
|
850
|
+
this.remoteGitPath,
|
|
851
|
+
'rev-parse',
|
|
852
|
+
'--verify',
|
|
853
|
+
`refs/heads/${branchRef}`,
|
|
854
|
+
]);
|
|
855
|
+
const sha = out.trim();
|
|
856
|
+
return sha.length > 0 ? sha : null;
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Absent from remote.git: this branch was never submitted, so none of
|
|
860
|
+
// its history was ever published. Not an error.
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Record that this worker rewrote `expectedSha` out of a branch's already
|
|
866
|
+
* published history (see BranchMetadata.historyRewrittenFrom).
|
|
867
|
+
*
|
|
868
|
+
* Set-once: if a marker is already present it is LEFT ALONE. Across two
|
|
869
|
+
* rebases before any GitHub push lands, GitHub still holds the commit the
|
|
870
|
+
* FIRST rebase replaced, so advancing the marker would aim the lease at a
|
|
871
|
+
* commit GitHub never had and permanently wedge the branch.
|
|
872
|
+
*
|
|
873
|
+
* Re-reads metadata rather than trusting the caller's loop-top snapshot:
|
|
874
|
+
* the task loop runs concurrently with syncGit() (see scheduleLoop) and may
|
|
875
|
+
* have cleared the marker while this branch was rebasing.
|
|
876
|
+
*/
|
|
877
|
+
async markHistoryRewritten(branchPath, branchDir, expectedSha) {
|
|
878
|
+
const current = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
879
|
+
if (typeof current?.branch.historyRewrittenFrom === 'string')
|
|
880
|
+
return;
|
|
881
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
882
|
+
await meta.save({ branch: { name: branchDir, historyRewrittenFrom: expectedSha } });
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Clear the marker once GitHub is confirmed to hold the rewritten history.
|
|
886
|
+
*
|
|
887
|
+
* Best-effort only in the sense that a failure here destroys nothing: the
|
|
888
|
+
* lease still refuses anything unexpected, and the plain-push fallback only
|
|
889
|
+
* ever fast-forwards. It is NOT harmless. A marker that outlives its
|
|
890
|
+
* episode can wedge the NEXT one -- if the base advances before the stale
|
|
891
|
+
* marker is revisited, the queued push leases a commit GitHub has already
|
|
892
|
+
* moved off, falls back to a plain push of a rebased (non-ancestor)
|
|
893
|
+
* history, and fails permanently with a "something else moved it on
|
|
894
|
+
* GitHub" diagnosis that is false: we did.
|
|
895
|
+
*
|
|
896
|
+
* Tracked, with the concurrent-clear race that can drop a marker
|
|
897
|
+
* mid-arming, in
|
|
898
|
+
* .claude/future-tasks/worker-history-rewrite-marker-races.md.
|
|
899
|
+
*/
|
|
900
|
+
async clearHistoryRewrittenMarker(branchPath, branchDir) {
|
|
901
|
+
try {
|
|
902
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
903
|
+
// Explicit undefined clears the key -- save()'s merge only overwrites
|
|
904
|
+
// keys present in the update (same pattern as rebaseFailure).
|
|
905
|
+
await meta.save({ branch: { name: branchDir, historyRewrittenFrom: undefined } });
|
|
906
|
+
}
|
|
907
|
+
catch (err) {
|
|
908
|
+
workerLogWarn(` Failed to clear history-rewrite marker for ${branchDir}: ${getErrorMessage(err)}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Publish a branch clone's rebased history into `remote.git`, replacing
|
|
913
|
+
* EXACTLY `expectedSha` and nothing else.
|
|
914
|
+
*
|
|
915
|
+
* The lease is the entire safety argument. `--force-with-lease=<ref>:<sha>`
|
|
916
|
+
* refuses unless `remote.git` still stands at `<sha>`, so this can only
|
|
917
|
+
* ever undo the commit our own rebase rewrote away. Callers must never pass
|
|
918
|
+
* "whatever remote.git currently holds" -- see the arming guard in
|
|
919
|
+
* rebaseActiveBranches() for the interleaving where that would silently
|
|
920
|
+
* delete a reviewer's direct push.
|
|
921
|
+
*
|
|
922
|
+
* Returns whether the push landed. A refused lease means a concurrent
|
|
923
|
+
* Lambda push moved the ref; that is logged and retried by the self-heal
|
|
924
|
+
* pass on a later cycle, never thrown.
|
|
925
|
+
*/
|
|
926
|
+
async forcePublishToLocalRemote(branchPath, branchRef, expectedSha) {
|
|
927
|
+
// A dedicated instance rather than the caller's: `.env()` replaces the
|
|
928
|
+
// whole child environment, and the rebase loop's instance must keep its
|
|
929
|
+
// ambient one. gitChildEnv (not gitNetworkChildEnv) because this push
|
|
930
|
+
// targets the local bare repo -- and the locale pin is what keeps
|
|
931
|
+
// isStaleLeaseRejection below from silently becoming a no-op.
|
|
932
|
+
const pushGit = simpleGit({ baseDir: branchPath, timeout: { block: this.taskTimeoutMs } });
|
|
933
|
+
pushGit.env(gitChildEnv({}));
|
|
934
|
+
try {
|
|
935
|
+
await pushGit.raw([
|
|
936
|
+
'push',
|
|
937
|
+
`--force-with-lease=${branchRef}:${expectedSha}`,
|
|
938
|
+
// Real flags must precede --end-of-options; everything after it is
|
|
939
|
+
// positional (see GitManager.push() for the same guard).
|
|
940
|
+
'--end-of-options',
|
|
941
|
+
'origin',
|
|
942
|
+
`${branchRef}:${branchRef}`,
|
|
943
|
+
]);
|
|
944
|
+
workerLog(` Published rebased ${branchRef} into remote.git`);
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
const message = redactCredentials(getErrorMessage(err));
|
|
949
|
+
workerLogWarn(isStaleLeaseRejection(message)
|
|
950
|
+
? ` Did not publish rebased ${branchRef} into remote.git: it moved since this cycle read it (concurrent submit?) -- retrying next cycle`
|
|
951
|
+
: ` Failed to publish rebased ${branchRef} into remote.git: ${message}`);
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Queue the GitHub hop for a branch whose rewritten history now sits in
|
|
957
|
+
* `remote.git`, so an open PR's head follows the rebase within a cycle
|
|
958
|
+
* instead of waiting for the editor's next submit.
|
|
959
|
+
*
|
|
960
|
+
* Deliberately NOT skipped when a marker is already set: inferring "a task
|
|
961
|
+
* must already be queued" from the marker starves this hop whenever a task
|
|
962
|
+
* was lost, failed permanently, or was never written. Duplicate push tasks
|
|
963
|
+
* are bounded by base-branch advances and are idempotent (a repeat push is
|
|
964
|
+
* a no-op once GitHub holds the tip).
|
|
965
|
+
*/
|
|
966
|
+
async enqueueGitHubPush(branchRef) {
|
|
967
|
+
try {
|
|
968
|
+
// Dedupe against tasks actually in flight rather than against the
|
|
969
|
+
// marker: a branch whose GitHub push keeps failing would otherwise
|
|
970
|
+
// gain one task per sync cycle forever.
|
|
971
|
+
for (const status of ['pending', 'processing']) {
|
|
972
|
+
const inFlight = await listTasks(this.taskDir, status);
|
|
973
|
+
if (inFlight.some((t) => t.action === 'push-branch' && t.payload.branch === branchRef)) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
await enqueueTask(this.taskDir, { action: 'push-branch', payload: { branch: branchRef } });
|
|
978
|
+
workerLog(` Queued GitHub push for ${branchRef}`);
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
workerLogWarn(` Failed to queue GitHub push for ${branchRef}: ${getErrorMessage(err)}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Complete a rewrite this worker started but did not finish: get the
|
|
986
|
+
* rebased history into `remote.git` and queued for GitHub.
|
|
987
|
+
*
|
|
988
|
+
* Runs from the rebase loop for any branch carrying a marker, whether or
|
|
989
|
+
* not it is behind base this cycle, so an interrupted publish converges
|
|
990
|
+
* without waiting for the next base-branch advance.
|
|
991
|
+
*
|
|
992
|
+
* Always leases on the MARKER, never on remote.git's current tip -- the
|
|
993
|
+
* marker is the one commit we know our own rebase replaced.
|
|
994
|
+
*/
|
|
995
|
+
async reconcilePendingRewrite(options) {
|
|
996
|
+
const { branchPath, branchDir, branchRef, headSha, marker } = options;
|
|
997
|
+
const publishedSha = await this.readPublishedSha(branchRef);
|
|
998
|
+
if (publishedSha === null) {
|
|
999
|
+
workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (publishedSha === headSha) {
|
|
1003
|
+
// remote.git already carries the rewrite; only the GitHub hop is left
|
|
1004
|
+
// (a crash between the push and the queue, or a task that was lost).
|
|
1005
|
+
await this.enqueueGitHubPush(branchRef);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (publishedSha === marker) {
|
|
1009
|
+
// The publish into remote.git never landed -- a crash right after the
|
|
1010
|
+
// marker was written, or a lease refused by a concurrent submit.
|
|
1011
|
+
if (await this.forcePublishToLocalRemote(branchPath, branchRef, marker)) {
|
|
1012
|
+
await this.enqueueGitHubPush(branchRef);
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
// Neither this branch's rebased tip nor the commit its rewrite replaced:
|
|
1017
|
+
// something else moved remote.git. Never force over that.
|
|
1018
|
+
workerLogWarn(` ${branchDir}: remote.git is at ${publishedSha} for ${branchRef}, which is neither the ` +
|
|
1019
|
+
`rebased tip nor the commit the rewrite replaced -- left untouched`);
|
|
1020
|
+
}
|
|
707
1021
|
async pushBranchToGitHub(branch) {
|
|
708
1022
|
const git = simpleGit({
|
|
709
1023
|
baseDir: this.remoteGitPath,
|
|
@@ -712,37 +1026,384 @@ export class CmsWorker {
|
|
|
712
1026
|
// it hang past the task timeout.
|
|
713
1027
|
timeout: { block: this.taskTimeoutMs },
|
|
714
1028
|
});
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
1029
|
+
// Force stable (English) git output so isNonFastForwardRejection below
|
|
1030
|
+
// can reliably match it -- git's rejection text is gettext-translated, so
|
|
1031
|
+
// a non-English host would silently turn that classifier into a no-op.
|
|
1032
|
+
// gitNetworkChildEnv (NOT gitChildEnv) because this call talks to GitHub:
|
|
1033
|
+
// it keeps ambient HTTPS_PROXY/GIT_SSL_*/GIT_SSH_COMMAND, which
|
|
1034
|
+
// gitChildEnv's local-ops allowlist deliberately drops.
|
|
1035
|
+
git.env(gitNetworkChildEnv());
|
|
1036
|
+
// [SYNC-H1] If the rebase loop rewrote this branch's already-published
|
|
1037
|
+
// history, GitHub still holds the commit it replaced, so an ordinary
|
|
1038
|
+
// push is non-fast-forward forever. Push under a lease keyed to exactly
|
|
1039
|
+
// that commit: it moves GitHub off the commit we rewrote away, and
|
|
1040
|
+
// refuses in every other case (including a commit someone else pushed).
|
|
1041
|
+
const branchPath = this.branchWorkspacePath(branch);
|
|
1042
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
|
|
1043
|
+
const marker = metaFile?.branch.historyRewrittenFrom;
|
|
1044
|
+
// What this push will actually send: remote.git's tip for the branch.
|
|
1045
|
+
const outgoingSha = await this.readPublishedSha(branch);
|
|
1046
|
+
try {
|
|
1047
|
+
if (marker) {
|
|
1048
|
+
await git.raw([
|
|
1049
|
+
'push',
|
|
1050
|
+
`--force-with-lease=${branch}:${marker}`,
|
|
1051
|
+
'--end-of-options',
|
|
1052
|
+
this.buildGitHubUrl(),
|
|
1053
|
+
`${branch}:${branch}`,
|
|
1054
|
+
]);
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
// Pass URL directly to avoid persisting the token in remote.git/config
|
|
1058
|
+
await git.push(this.buildGitHubUrl(), branch);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
const message = getErrorMessage(err);
|
|
1063
|
+
// A refused lease means GitHub is not at the commit we rewrote, so the
|
|
1064
|
+
// marker is stale -- which is routine, not exceptional: tasks are
|
|
1065
|
+
// re-run after a crash (recoverOrphanedTasks), and the marker survives
|
|
1066
|
+
// any failure to clear it. Two benign shapes reach here, and both are
|
|
1067
|
+
// ordinary fast-forwards that a lease has no business blocking:
|
|
1068
|
+
// GitHub already holds the rewritten history from an earlier attempt,
|
|
1069
|
+
// or the branch has since moved past it.
|
|
1070
|
+
//
|
|
1071
|
+
// Retry PLAIN, and let git adjudicate. A non-forced push succeeds if
|
|
1072
|
+
// and only if it fast-forwards, so it can never destroy anything --
|
|
1073
|
+
// there is no ancestry check to get wrong here, and no extra network
|
|
1074
|
+
// round trip to read GitHub's tip. Only if THAT is also rejected has
|
|
1075
|
+
// the branch genuinely diverged.
|
|
1076
|
+
//
|
|
1077
|
+
// (Verified: git evaluates the lease only when it actually has an
|
|
1078
|
+
// update to apply. An up-to-date ref with a stale lease prints
|
|
1079
|
+
// "Everything up-to-date" and exits 0, so the already-landed case is
|
|
1080
|
+
// usually absorbed above and never even reaches this branch.)
|
|
1081
|
+
if (marker && isStaleLeaseRejection(message)) {
|
|
1082
|
+
try {
|
|
1083
|
+
await git.push(this.buildGitHubUrl(), branch);
|
|
1084
|
+
}
|
|
1085
|
+
catch (retryErr) {
|
|
1086
|
+
const retryMessage = getErrorMessage(retryErr);
|
|
1087
|
+
if (isNonFastForwardRejection(retryMessage)) {
|
|
1088
|
+
throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
|
|
1089
|
+
`deployment last published nor an ancestor of what it is pushing, so the branch ` +
|
|
1090
|
+
`has genuinely diverged and nothing was overwritten. Something else moved it on ` +
|
|
1091
|
+
`GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
|
|
1092
|
+
}
|
|
1093
|
+
throw retryErr;
|
|
1094
|
+
}
|
|
1095
|
+
// The lease was refused, so GitHub is provably not at the marker: it
|
|
1096
|
+
// has moved past the rewritten commit and the marker is spent.
|
|
1097
|
+
await this.clearHistoryRewrittenMarker(branchPath, branch);
|
|
1098
|
+
workerLog(`Pushed ${branch} to GitHub (GitHub had already moved past the rewritten commit)`);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
// An ordinary non-fast-forward rejection: GitHub has commits this
|
|
1102
|
+
// deployment never published. Retrying the identical push can never
|
|
1103
|
+
// succeed (DEP-L1's git-failure-is-transient carve-out does NOT apply
|
|
1104
|
+
// here), so fail fast instead of burning the task's retry budget.
|
|
1105
|
+
// Deliberately does NOT advise renaming the branch: a branch reaching
|
|
1106
|
+
// this point usually has an open PR, and renaming would orphan it.
|
|
1107
|
+
if (isNonFastForwardRejection(message)) {
|
|
1108
|
+
throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
|
|
1109
|
+
`published, so the branch has diverged and needs reconciling. Something else moved it ` +
|
|
1110
|
+
`on GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
|
|
1111
|
+
}
|
|
1112
|
+
throw err;
|
|
1113
|
+
}
|
|
1114
|
+
// Clear the marker only once GitHub is confirmed to hold something other
|
|
1115
|
+
// than the commit we rewrote. A push that sent nothing new (remote.git
|
|
1116
|
+
// still at the marker because its own publish has not landed yet) must
|
|
1117
|
+
// leave the marker set -- it is the sole trigger for the self-heal pass.
|
|
1118
|
+
if (marker && outgoingSha && outgoingSha !== marker) {
|
|
1119
|
+
await this.clearHistoryRewrittenMarker(branchPath, branch);
|
|
1120
|
+
}
|
|
1121
|
+
workerLog(`Pushed ${branch} to GitHub`);
|
|
718
1122
|
}
|
|
719
1123
|
/**
|
|
720
|
-
* Push
|
|
721
|
-
* Non-fatal: a no-op push for up-to-date
|
|
1124
|
+
* Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
|
|
1125
|
+
* remote.git to GitHub. Non-fatal: a no-op push for an up-to-date branch
|
|
1126
|
+
* just succeeds quietly.
|
|
1127
|
+
*
|
|
1128
|
+
* Deliberately narrowed to one branch — this used to push EVERY local
|
|
1129
|
+
* branch matching `canopycms-settings-*`. With the tracking-namespace fetch
|
|
1130
|
+
* fix (GITHUB_TRACKING_REF_PREFIX), `reconcileTrackedBranches` creates local
|
|
1131
|
+
* heads for branches that exist on GitHub, so ANOTHER deployment's settings
|
|
1132
|
+
* branch (sharing this same GitHub repo) can legitimately show up as a
|
|
1133
|
+
* local head here too. Pushing it would be this deployment shipping
|
|
1134
|
+
* settings state it doesn't own.
|
|
722
1135
|
*/
|
|
723
|
-
async pushSettingsBranches(git) {
|
|
1136
|
+
async pushSettingsBranches(git, trackedNames) {
|
|
724
1137
|
try {
|
|
1138
|
+
// Resolved once here rather than at each use below: ensureSettingsBranch()
|
|
1139
|
+
// is idempotent, but a local reads better and keeps the eight references
|
|
1140
|
+
// in this method obviously talking about one value.
|
|
1141
|
+
const settingsBranch = this.ensureSettingsBranch();
|
|
725
1142
|
const branches = await git.branch();
|
|
726
|
-
const settingsBranches = branches.all.filter((b) => b.startsWith(
|
|
727
|
-
|
|
1143
|
+
const settingsBranches = branches.all.filter((b) => b.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX));
|
|
1144
|
+
const foreign = settingsBranches.filter((b) => b !== settingsBranch);
|
|
1145
|
+
if (foreign.length > 0) {
|
|
1146
|
+
// Signal, not an error: this is exactly the "two deployments, one repo"
|
|
1147
|
+
// condition this workstream exists to make visible. Never push these.
|
|
1148
|
+
workerLogWarn(`Found settings branch(es) not owned by this deployment (${settingsBranch}): ` +
|
|
1149
|
+
`${foreign.join(', ')}. Another CanopyCMS deployment may share this GitHub repo. Not pushing them.`);
|
|
1150
|
+
}
|
|
1151
|
+
// Check the full branch list, not the `canopycms-settings-*` subset: an
|
|
1152
|
+
// adopter-supplied `settingsBranch` override need not carry that prefix.
|
|
1153
|
+
const ownBranchMissing = !branches.all.includes(settingsBranch);
|
|
1154
|
+
// [SYNC-M3] A settings branch present in remote.git but absent from
|
|
1155
|
+
// GitHub's tracking refs was pushed here LOCALLY -- only this
|
|
1156
|
+
// deployment's own API can write to remote.git -- and has never
|
|
1157
|
+
// reached GitHub. That is the discriminating signature: in the
|
|
1158
|
+
// SUPPORTED two-deployments-one-repo case the foreign branch arrives
|
|
1159
|
+
// through the GitHub fetch and therefore always has a tracking ref, so
|
|
1160
|
+
// the warn above cannot tell the two apart on its own and a
|
|
1161
|
+
// "owned-branch-absent" test alone would fire on every deployment that
|
|
1162
|
+
// simply has not had a settings edit yet.
|
|
1163
|
+
//
|
|
1164
|
+
// With this deployment's own branch also missing, the API and this
|
|
1165
|
+
// worker have resolved different deploymentNames, and every settings
|
|
1166
|
+
// change the API commits is stranded in remote.git forever.
|
|
1167
|
+
const strandedLocal = foreign.filter((b) => !trackedNames.has(b));
|
|
1168
|
+
if (strandedLocal.length > 0) {
|
|
1169
|
+
workerLogWarn(ownBranchMissing
|
|
1170
|
+
? `Settings branch mismatch: this worker owns "${settingsBranch}", which does not ` +
|
|
1171
|
+
`exist in remote.git, while ${strandedLocal.join(', ')} exist(s) here and has never ` +
|
|
1172
|
+
`been pushed to GitHub. The API and this worker disagree about deploymentName, so ` +
|
|
1173
|
+
`settings changes are NOT reaching GitHub. Set CANOPYCMS_DEPLOYMENT_NAME (or ` +
|
|
1174
|
+
`settingsBranch) on this worker to match what the API resolves.`
|
|
1175
|
+
: `Settings branch(es) ${strandedLocal.join(', ')} exist in remote.git but not on ` +
|
|
1176
|
+
`GitHub, and are not owned by this deployment (${settingsBranch}) -- nothing ` +
|
|
1177
|
+
`will ever push them onward. Check that deploymentName matches across this ` +
|
|
1178
|
+
`deployment's API and worker.`);
|
|
1179
|
+
}
|
|
1180
|
+
if (ownBranchMissing) {
|
|
1181
|
+
// Not created locally yet — nothing to push.
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
await git.push(this.buildGitHubUrl(), settingsBranch);
|
|
1186
|
+
workerLog(`Pushed settings branch ${settingsBranch} to GitHub`);
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
// Non-fatal: branch may already be up-to-date. This call site has no
|
|
1190
|
+
// task to throw a PermanentTaskError into (unlike pushBranchToGitHub)
|
|
1191
|
+
// and is deliberately not restructured to add one -- but a
|
|
1192
|
+
// non-fast-forward rejection here is the highest-signal instance of
|
|
1193
|
+
// this whole failure class: it means another CanopyCMS deployment's
|
|
1194
|
+
// worker already pushed ITS OWN state to its own settings branch on
|
|
1195
|
+
// GitHub (an actual settings-branch name collision, not just the
|
|
1196
|
+
// "foreign branch found locally" case warned about above), so make
|
|
1197
|
+
// that explicit instead of a generic "push failed" line.
|
|
1198
|
+
const message = getErrorMessage(err);
|
|
1199
|
+
if (isNonFastForwardRejection(message)) {
|
|
1200
|
+
workerLogWarn(`Settings push for ${settingsBranch} was rejected (non-fast-forward): another ` +
|
|
1201
|
+
`CanopyCMS deployment appears to own this settings branch on GitHub. Settings from ` +
|
|
1202
|
+
`that deployment will NOT be overwritten; this deployment's local settings changes ` +
|
|
1203
|
+
`were not pushed. Rename this deployment's settings branch (config.settingsBranch or ` +
|
|
1204
|
+
`deploymentName) to resolve the collision.`);
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
workerLogWarn(`Settings push for ${settingsBranch}:`, message);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
workerLogWarn('Failed to list branches for settings push:', err instanceof Error ? err.message : err);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Bring `refs/heads/*` in `remote.git` toward what was just fetched into
|
|
1217
|
+
* `GITHUB_TRACKING_REF_PREFIX` -- WITHOUT ever force-rewinding or deleting
|
|
1218
|
+
* a local head. This is the non-destructive replacement for what the old
|
|
1219
|
+
* `+refs/heads/*:refs/heads/*` fetch refspec used to do implicitly (and
|
|
1220
|
+
* destructively) as part of the fetch itself; see
|
|
1221
|
+
* `GITHUB_TRACKING_REF_PREFIX`'s doc comment for the two failure modes
|
|
1222
|
+
* that refspec caused.
|
|
1223
|
+
*
|
|
1224
|
+
* Per tracked branch:
|
|
1225
|
+
* - no local `refs/heads/<name>` yet -> create it at the tracked commit
|
|
1226
|
+
* (a branch created on GitHub, or by another deployment sharing this
|
|
1227
|
+
* GitHub repo, becomes visible locally).
|
|
1228
|
+
* - local is a strict ancestor of tracked (behind) -> fast-forward it.
|
|
1229
|
+
* - local === tracked -> nothing to do.
|
|
1230
|
+
* - tracked is a strict ancestor of local (ahead) -> LEAVE IT ALONE. This
|
|
1231
|
+
* is unpushed editor/settings work; the queued push task (or
|
|
1232
|
+
* pushSettingsBranches) ships it. This is exactly the branch state the
|
|
1233
|
+
* old refspec used to destroy.
|
|
1234
|
+
* - neither is an ancestor of the other (diverged) -> LEAVE IT ALONE and
|
|
1235
|
+
* count/log it. A real collision (e.g. another deployment moved the
|
|
1236
|
+
* same branch name on GitHub); the next push attempt will be rejected
|
|
1237
|
+
* non-fast-forward, which is the correct, visible outcome -- this
|
|
1238
|
+
* method must never silently pick a winner. The one expected, benign
|
|
1239
|
+
* form of this -- our own rebase loop having published a rewrite into
|
|
1240
|
+
* remote.git with the GitHub push still queued -- is split out into the
|
|
1241
|
+
* `rewritten` bucket so the collision warning stays meaningful.
|
|
1242
|
+
*
|
|
1243
|
+
* Never deletes a local head: a branch removed on GitHub simply stops
|
|
1244
|
+
* being tracked here; the local ref persists until removed through its
|
|
1245
|
+
* own explicit path (the sync loop must not be one of them).
|
|
1246
|
+
*
|
|
1247
|
+
* `remote.git` is bare, so there is no worktree to invalidate by moving
|
|
1248
|
+
* these refs -- unlike a non-bare repo, updating the ref that happens to
|
|
1249
|
+
* be "checked out" is a non-issue here.
|
|
1250
|
+
*
|
|
1251
|
+
* Concurrency: `remote.git` is bare and on EFS, and the Lambda pushes into
|
|
1252
|
+
* it concurrently (`GitManager.push()`'s `target:target` refspec) while
|
|
1253
|
+
* this runs. Every `update-ref` below passes the expected old value (the
|
|
1254
|
+
* all-zeros OID for "must not exist yet" on creation, the previously-read
|
|
1255
|
+
* SHA for the fast-forward case) so a concurrent Lambda write landing in
|
|
1256
|
+
* the gap between the read and the write loses the ref update instead of
|
|
1257
|
+
* being silently clobbered -- the branch is simply revisited next cycle.
|
|
1258
|
+
*/
|
|
1259
|
+
async reconcileTrackedBranches(git) {
|
|
1260
|
+
const GIT_ZERO_OID = '0000000000000000000000000000000000000000';
|
|
1261
|
+
const created = [];
|
|
1262
|
+
const fastForwarded = [];
|
|
1263
|
+
const ahead = [];
|
|
1264
|
+
const diverged = [];
|
|
1265
|
+
const rewritten = [];
|
|
1266
|
+
// One invocation enumerates both namespaces: refs/heads/<name> (what the
|
|
1267
|
+
// Lambda pushes into and branch clones read from) and
|
|
1268
|
+
// GITHUB_TRACKING_REF_PREFIX<name> (GitHub's tip, just fetched above).
|
|
1269
|
+
const raw = await git.raw([
|
|
1270
|
+
'for-each-ref',
|
|
1271
|
+
'--format=%(refname) %(objectname)',
|
|
1272
|
+
'refs/heads/',
|
|
1273
|
+
GITHUB_TRACKING_REF_PREFIX,
|
|
1274
|
+
]);
|
|
1275
|
+
const heads = new Map();
|
|
1276
|
+
const tracked = new Map();
|
|
1277
|
+
for (const line of raw.split('\n')) {
|
|
1278
|
+
const trimmed = line.trim();
|
|
1279
|
+
if (!trimmed)
|
|
1280
|
+
continue;
|
|
1281
|
+
const [refname, sha] = trimmed.split(' ');
|
|
1282
|
+
if (refname.startsWith('refs/heads/')) {
|
|
1283
|
+
heads.set(refname.slice('refs/heads/'.length), sha);
|
|
1284
|
+
}
|
|
1285
|
+
else if (refname.startsWith(GITHUB_TRACKING_REF_PREFIX)) {
|
|
1286
|
+
tracked.set(refname.slice(GITHUB_TRACKING_REF_PREFIX.length), sha);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
for (const [name, trackedSha] of tracked) {
|
|
1290
|
+
const localSha = heads.get(name);
|
|
1291
|
+
const localRef = `refs/heads/${name}`;
|
|
1292
|
+
if (!localSha) {
|
|
728
1293
|
try {
|
|
729
|
-
|
|
730
|
-
|
|
1294
|
+
// Zero old-value asserts the ref does not already exist -- guards
|
|
1295
|
+
// against a concurrent Lambda push creating this exact branch
|
|
1296
|
+
// name between the for-each-ref read above and this update.
|
|
1297
|
+
await git.raw(['update-ref', localRef, trackedSha, GIT_ZERO_OID]);
|
|
1298
|
+
created.push(name);
|
|
731
1299
|
}
|
|
732
1300
|
catch (err) {
|
|
733
|
-
|
|
734
|
-
console.warn(`Settings push for ${branch}:`, err instanceof Error ? err.message : err);
|
|
1301
|
+
workerLogWarn(` Tracked-branch reconcile: failed to create local ref for ${name} (concurrent update?): ${getErrorMessage(err)}`);
|
|
735
1302
|
}
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (localSha === trackedSha)
|
|
1306
|
+
continue; // nothing to do
|
|
1307
|
+
// [SYNC-M2] Everything below is per-branch best-effort. The rev-list
|
|
1308
|
+
// used to sit between the two guarded update-ref calls with no guard
|
|
1309
|
+
// of its own, so a single ref pointing at a missing or partially
|
|
1310
|
+
// written object -- plausible on EFS with the Lambda writing
|
|
1311
|
+
// concurrently -- threw out of this loop, out of syncGit()'s try, and
|
|
1312
|
+
// skipped pushSettingsBranches(), refreshBaseBranchWorkspace() and
|
|
1313
|
+
// rebaseActiveBranches() entirely. Nothing self-healed, so it recurred
|
|
1314
|
+
// every cycle. One unreadable ref must cost its own branch, not the
|
|
1315
|
+
// whole sync cycle.
|
|
1316
|
+
try {
|
|
1317
|
+
// Count commits unique to each side in one call: left = commits local
|
|
1318
|
+
// has that tracked doesn't (ahead), right = commits tracked has that
|
|
1319
|
+
// local doesn't (behind). Exits 0 regardless of ancestry direction,
|
|
1320
|
+
// unlike `merge-base --is-ancestor` (which simple-git would throw on
|
|
1321
|
+
// a non-zero exit for the common "not an ancestor" case).
|
|
1322
|
+
const counts = (await git.raw(['rev-list', '--left-right', '--count', `${localSha}...${trackedSha}`])).trim();
|
|
1323
|
+
const [leftStr, rightStr] = counts.split(/\s+/);
|
|
1324
|
+
const localAheadCount = parseInt(leftStr, 10);
|
|
1325
|
+
const localBehindCount = parseInt(rightStr, 10);
|
|
1326
|
+
if (!Number.isInteger(localAheadCount) || !Number.isInteger(localBehindCount)) {
|
|
1327
|
+
// Unparseable output means we could not read this branch, NOT that
|
|
1328
|
+
// it diverged: falling through to the `diverged` bucket below would
|
|
1329
|
+
// warn operators about a cross-deployment collision that never
|
|
1330
|
+
// happened (parseInt yields NaN, and NaN fails both comparisons).
|
|
1331
|
+
workerLogWarn(` Tracked-branch reconcile: skipping ${name}: unparseable rev-list output ${JSON.stringify(counts)}`);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (localAheadCount === 0 && localBehindCount > 0) {
|
|
1335
|
+
try {
|
|
1336
|
+
await git.raw(['update-ref', localRef, trackedSha, localSha]);
|
|
1337
|
+
fastForwarded.push(name);
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
// Concurrent Lambda push moved the ref since the read above --
|
|
1341
|
+
// the guard did its job; this branch is simply revisited next cycle.
|
|
1342
|
+
workerLogWarn(` Tracked-branch reconcile: failed to fast-forward ${name} (concurrent update?): ${getErrorMessage(err)}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
else if (localBehindCount === 0 && localAheadCount > 0) {
|
|
1346
|
+
// Unpushed local work -- exactly what the old destructive refspec
|
|
1347
|
+
// used to force-rewind or delete. Leave it.
|
|
1348
|
+
ahead.push(name);
|
|
1349
|
+
}
|
|
1350
|
+
else if (await this.hasPendingHistoryRewrite(name)) {
|
|
1351
|
+
// [SYNC-H1] Our own rebase published a rewrite into remote.git and
|
|
1352
|
+
// the GitHub push has not landed yet. Ref-level this is identical
|
|
1353
|
+
// to a collision, but it is expected and self-resolving, so it must
|
|
1354
|
+
// not fire the collision warning below.
|
|
1355
|
+
rewritten.push(name);
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
// Neither side is an ancestor of the other. Leave both alone.
|
|
1359
|
+
diverged.push(name);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
workerLogWarn(` Tracked-branch reconcile: skipping ${name} (unreadable ref or object?): ${getErrorMessage(err)}`);
|
|
1364
|
+
continue;
|
|
736
1365
|
}
|
|
737
1366
|
}
|
|
738
|
-
|
|
739
|
-
|
|
1367
|
+
if (diverged.length > 0) {
|
|
1368
|
+
workerLogWarn(` Tracked-branch reconcile: ${diverged.length} branch(es) diverged from GitHub and were left untouched: ${diverged.join(', ')}`);
|
|
1369
|
+
}
|
|
1370
|
+
if (rewritten.length > 0) {
|
|
1371
|
+
workerLog(` Tracked-branch reconcile: ${rewritten.length} branch(es) rebased locally with the GitHub push still pending: ${rewritten.join(', ')}`);
|
|
1372
|
+
}
|
|
1373
|
+
// trackedNames is returned alongside the summary (rather than folded into
|
|
1374
|
+
// it) because it is a working set for pushSettingsBranches' stranded-
|
|
1375
|
+
// branch check, not part of the worker-status snapshot -- it lists every
|
|
1376
|
+
// branch on GitHub and would bloat worker-status.json for no reader.
|
|
1377
|
+
return {
|
|
1378
|
+
summary: { created, fastForwarded, ahead, diverged, rewritten },
|
|
1379
|
+
trackedNames: new Set(tracked.keys()),
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Whether this branch carries a pending history rewrite -- the rebase loop
|
|
1384
|
+
* rewrote already-published history and the GitHub push has not landed yet
|
|
1385
|
+
* (see BranchMetadata.historyRewrittenFrom).
|
|
1386
|
+
*
|
|
1387
|
+
* `branchName` is a git ref name; branch workspaces are directories named
|
|
1388
|
+
* with the sanitized form, hence the conversion. Best-effort: a settings
|
|
1389
|
+
* branch (no workspace at all), a missing directory or an unreadable
|
|
1390
|
+
* branch.json all mean "no known rewrite", which is the conservative
|
|
1391
|
+
* answer -- it keeps the branch in the louder `diverged` bucket.
|
|
1392
|
+
*/
|
|
1393
|
+
async hasPendingHistoryRewrite(branchName) {
|
|
1394
|
+
try {
|
|
1395
|
+
const branchPath = path.join(this.contentBranchesPath, sanitizeBranchName(branchName));
|
|
1396
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1397
|
+
return typeof metaFile?.branch.historyRewrittenFrom === 'string';
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return false;
|
|
740
1401
|
}
|
|
741
1402
|
}
|
|
742
1403
|
async syncGit() {
|
|
743
1404
|
if (!this.running)
|
|
744
1405
|
return;
|
|
745
|
-
|
|
1406
|
+
workerLog('Syncing git...');
|
|
746
1407
|
const cycleStartedAt = Date.now();
|
|
747
1408
|
const git = simpleGit({
|
|
748
1409
|
baseDir: this.remoteGitPath,
|
|
@@ -751,6 +1412,11 @@ export class CmsWorker {
|
|
|
751
1412
|
// is inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
752
1413
|
timeout: { block: this.taskTimeoutMs },
|
|
753
1414
|
});
|
|
1415
|
+
// This instance also drives pushSettingsBranches(git) below, whose
|
|
1416
|
+
// classification of a rejected settings-branch push (see that method)
|
|
1417
|
+
// needs the same stable-English guarantee as pushBranchToGitHub. Network
|
|
1418
|
+
// env for the same reason: the fetch and push here reach GitHub.
|
|
1419
|
+
git.env(gitNetworkChildEnv());
|
|
754
1420
|
// PR-W1: the whole cycle is wrapped so both outcomes -- success and
|
|
755
1421
|
// hard failure (e.g. the fetch throwing against a poisoned remote.git)
|
|
756
1422
|
// -- record a worker-status.json snapshot. The status write itself is
|
|
@@ -759,14 +1425,28 @@ export class CmsWorker {
|
|
|
759
1425
|
// on a failed one. On failure we rethrow so scheduleLoop's existing
|
|
760
1426
|
// per-cycle catch stays the loud path.
|
|
761
1427
|
try {
|
|
762
|
-
// Fetch all branches from GitHub using direct URL (no named remote)
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
1428
|
+
// Fetch all branches from GitHub using direct URL (no named remote),
|
|
1429
|
+
// into the GITHUB_TRACKING_REF_PREFIX remote-tracking namespace rather
|
|
1430
|
+
// than refs/heads/* directly -- see that constant's doc comment for
|
|
1431
|
+
// the destructive-fetch bug this avoids. We use raw git commands since
|
|
1432
|
+
// simple-git's fetch() with a URL doesn't support --prune directly.
|
|
1433
|
+
await git.raw([
|
|
1434
|
+
'fetch',
|
|
1435
|
+
this.buildGitHubUrl(),
|
|
1436
|
+
'--prune',
|
|
1437
|
+
`+refs/heads/*:${GITHUB_TRACKING_REF_PREFIX}*`,
|
|
1438
|
+
]);
|
|
1439
|
+
workerLog('Fetched from GitHub');
|
|
1440
|
+
// Bring refs/heads/* toward what was just fetched, WITHOUT ever
|
|
1441
|
+
// force-rewinding or deleting a local head -- see
|
|
1442
|
+
// reconcileTrackedBranches()'s doc comment.
|
|
1443
|
+
const { summary: trackedSummary, trackedNames } = await this.reconcileTrackedBranches(git);
|
|
767
1444
|
// Push settings branches to GitHub (belt-and-suspenders for task queue).
|
|
768
1445
|
// Ensures settings reach GitHub even if a task queue entry is lost.
|
|
769
|
-
|
|
1446
|
+
// Ordering relative to the fetch/reconcile above is no longer a
|
|
1447
|
+
// correctness dependency now that the fetch can't clobber refs/heads/*
|
|
1448
|
+
// -- this could run before or after them just as safely.
|
|
1449
|
+
await this.pushSettingsBranches(git, trackedNames);
|
|
770
1450
|
await this.refreshBaseBranchWorkspace();
|
|
771
1451
|
const rebaseSummary = await this.rebaseActiveBranches();
|
|
772
1452
|
// Periodically clean up old completed/failed tasks
|
|
@@ -777,7 +1457,7 @@ export class CmsWorker {
|
|
|
777
1457
|
// place actual removal happens.
|
|
778
1458
|
const trashRemoved = await this.cleanupTrashedBranchDirs();
|
|
779
1459
|
if (trashRemoved > 0) {
|
|
780
|
-
|
|
1460
|
+
workerLog(`Removed ${trashRemoved} expired trashed branch dir(s)`);
|
|
781
1461
|
}
|
|
782
1462
|
const report = this.ensureStatusReport();
|
|
783
1463
|
report.lastGitSyncAt = new Date().toISOString();
|
|
@@ -786,9 +1466,11 @@ export class CmsWorker {
|
|
|
786
1466
|
durationMs: Date.now() - cycleStartedAt,
|
|
787
1467
|
rebased: rebaseSummary.rebased,
|
|
788
1468
|
skippedDirty: rebaseSummary.skippedDirty,
|
|
1469
|
+
skippedLocked: rebaseSummary.skippedLocked,
|
|
789
1470
|
failed: rebaseSummary.failed,
|
|
1471
|
+
tracked: trackedSummary,
|
|
790
1472
|
};
|
|
791
|
-
await writeWorkerStatus(this.taskDir, report).catch((writeErr) =>
|
|
1473
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
792
1474
|
}
|
|
793
1475
|
catch (err) {
|
|
794
1476
|
const report = this.ensureStatusReport();
|
|
@@ -799,7 +1481,7 @@ export class CmsWorker {
|
|
|
799
1481
|
message: redactCredentials(getErrorMessage(err)),
|
|
800
1482
|
at: new Date().toISOString(),
|
|
801
1483
|
};
|
|
802
|
-
await writeWorkerStatus(this.taskDir, report).catch((writeErr) =>
|
|
1484
|
+
await writeWorkerStatus(this.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
803
1485
|
throw err;
|
|
804
1486
|
}
|
|
805
1487
|
}
|
|
@@ -833,7 +1515,7 @@ export class CmsWorker {
|
|
|
833
1515
|
const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
|
|
834
1516
|
if (!stampDate) {
|
|
835
1517
|
if (!loggedUnparseable) {
|
|
836
|
-
|
|
1518
|
+
workerLog(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
|
|
837
1519
|
loggedUnparseable = true;
|
|
838
1520
|
}
|
|
839
1521
|
continue;
|
|
@@ -850,7 +1532,7 @@ export class CmsWorker {
|
|
|
850
1532
|
removed++;
|
|
851
1533
|
}
|
|
852
1534
|
catch (err) {
|
|
853
|
-
|
|
1535
|
+
workerLogError(`CanopyCMS: Failed to remove trashed branch dir ${name}:`, getErrorMessage(err));
|
|
854
1536
|
}
|
|
855
1537
|
}
|
|
856
1538
|
return removed;
|
|
@@ -890,7 +1572,7 @@ export class CmsWorker {
|
|
|
890
1572
|
gitDirStat = null;
|
|
891
1573
|
}
|
|
892
1574
|
if (!gitDirStat || !gitDirStat.isDirectory()) {
|
|
893
|
-
|
|
1575
|
+
workerLog(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
|
|
894
1576
|
return;
|
|
895
1577
|
}
|
|
896
1578
|
const baseGit = simpleGit({
|
|
@@ -921,7 +1603,7 @@ export class CmsWorker {
|
|
|
921
1603
|
const status = await baseGit.status();
|
|
922
1604
|
const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
|
|
923
1605
|
if (trackedDirty.length > 0) {
|
|
924
|
-
|
|
1606
|
+
workerLogError(`Base branch workspace (${this.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
|
|
925
1607
|
return;
|
|
926
1608
|
}
|
|
927
1609
|
// Raw (unsanitized) name from here on: these are git ref operations
|
|
@@ -945,7 +1627,7 @@ export class CmsWorker {
|
|
|
945
1627
|
await baseGit.merge(['--ff-only', fetchedTip]);
|
|
946
1628
|
}
|
|
947
1629
|
catch (err) {
|
|
948
|
-
|
|
1630
|
+
workerLogError(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
|
|
949
1631
|
return;
|
|
950
1632
|
}
|
|
951
1633
|
await invalidateBranchContentCaches(basePath);
|
|
@@ -968,12 +1650,12 @@ export class CmsWorker {
|
|
|
968
1650
|
});
|
|
969
1651
|
}
|
|
970
1652
|
// One concise per-cycle line -- the diagnostic for the next live deploy.
|
|
971
|
-
|
|
1653
|
+
workerLog(behindCount > 0
|
|
972
1654
|
? `Base branch workspace (${this.baseBranch}): fast-forwarded ${behindCount} commit(s)`
|
|
973
1655
|
: `Base branch workspace (${this.baseBranch}): up to date`);
|
|
974
1656
|
}
|
|
975
1657
|
catch (err) {
|
|
976
|
-
|
|
1658
|
+
workerLogError(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
|
|
977
1659
|
}
|
|
978
1660
|
}
|
|
979
1661
|
/**
|
|
@@ -1008,7 +1690,7 @@ export class CmsWorker {
|
|
|
1008
1690
|
await meta.save({
|
|
1009
1691
|
branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
|
|
1010
1692
|
});
|
|
1011
|
-
|
|
1693
|
+
workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
1012
1694
|
return;
|
|
1013
1695
|
}
|
|
1014
1696
|
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
@@ -1020,11 +1702,11 @@ export class CmsWorker {
|
|
|
1020
1702
|
return;
|
|
1021
1703
|
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
1022
1704
|
await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
|
|
1023
|
-
|
|
1705
|
+
workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
|
|
1024
1706
|
}
|
|
1025
1707
|
catch (err) {
|
|
1026
1708
|
// Non-fatal: transient GitHub/network errors are retried next cycle.
|
|
1027
|
-
|
|
1709
|
+
workerLogWarn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
|
|
1028
1710
|
}
|
|
1029
1711
|
finally {
|
|
1030
1712
|
clearTimeout(timer);
|
|
@@ -1082,22 +1764,36 @@ export class CmsWorker {
|
|
|
1082
1764
|
// against the same corrupt file would just throw again) as well as
|
|
1083
1765
|
// any other load/save failure -- recording is best-effort
|
|
1084
1766
|
// observability, never allowed to abort the branch loop.
|
|
1085
|
-
|
|
1767
|
+
workerLogWarn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
|
|
1086
1768
|
}
|
|
1087
1769
|
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Test-only seam: awaited inside `rebaseActiveBranches()`'s conflict round,
|
|
1772
|
+
* after `git rebase` reported conflicted files and BEFORE the
|
|
1773
|
+
* `checkout --theirs` resolution loop overwrites them. No-op in production.
|
|
1774
|
+
*
|
|
1775
|
+
* A test subclass overrides this to land a real `ContentStore` write at
|
|
1776
|
+
* exactly the instant the rebase is mid-flight -- the window the old TOCTOU
|
|
1777
|
+
* comment above the dirty check wrongly called safe -- without any sleeps or
|
|
1778
|
+
* shell rendezvous. See the "Deterministic interleavings" testing pattern in
|
|
1779
|
+
* docs/concurrency.md, and `ContentStore.afterPrePassForTesting()` for the
|
|
1780
|
+
* same idiom on the write side.
|
|
1781
|
+
*/
|
|
1782
|
+
async afterConflictDetectedForTesting() { }
|
|
1088
1783
|
async rebaseActiveBranches() {
|
|
1089
1784
|
// PR-W1: collected across the loop below and returned as a summary
|
|
1090
1785
|
// (folded into worker-status.json by syncGit()). Purely additive
|
|
1091
1786
|
// bookkeeping -- doesn't change any control flow or existing logging.
|
|
1092
1787
|
const rebased = [];
|
|
1093
1788
|
const skippedDirty = [];
|
|
1789
|
+
const skippedLocked = [];
|
|
1094
1790
|
const failed = [];
|
|
1095
1791
|
let branchDirs;
|
|
1096
1792
|
try {
|
|
1097
1793
|
branchDirs = await fs.readdir(this.contentBranchesPath);
|
|
1098
1794
|
}
|
|
1099
1795
|
catch {
|
|
1100
|
-
return { rebased, skippedDirty, failed };
|
|
1796
|
+
return { rebased, skippedDirty, skippedLocked, failed };
|
|
1101
1797
|
}
|
|
1102
1798
|
for (const branchDir of branchDirs) {
|
|
1103
1799
|
// Known structural entries under content-branches/, not branch
|
|
@@ -1113,12 +1809,12 @@ export class CmsWorker {
|
|
|
1113
1809
|
try {
|
|
1114
1810
|
const stat = await fs.stat(gitDir);
|
|
1115
1811
|
if (!stat.isDirectory()) {
|
|
1116
|
-
|
|
1812
|
+
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
1117
1813
|
continue;
|
|
1118
1814
|
}
|
|
1119
1815
|
}
|
|
1120
1816
|
catch {
|
|
1121
|
-
|
|
1817
|
+
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
1122
1818
|
continue;
|
|
1123
1819
|
}
|
|
1124
1820
|
// The base branch's own clone is refreshed ff-only by
|
|
@@ -1128,7 +1824,7 @@ export class CmsWorker {
|
|
|
1128
1824
|
// metadata on it. Compare sanitized-vs-sanitized: branchDir is a
|
|
1129
1825
|
// filesystem name (already sanitized), this.baseBranch is raw.
|
|
1130
1826
|
if (branchDir === this.sanitizedBaseBranch) {
|
|
1131
|
-
|
|
1827
|
+
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
1132
1828
|
continue;
|
|
1133
1829
|
}
|
|
1134
1830
|
try {
|
|
@@ -1142,12 +1838,12 @@ export class CmsWorker {
|
|
|
1142
1838
|
// - archived: already merged, no reason to rebase and no PR left to
|
|
1143
1839
|
// poll (avoid the wasted API call).
|
|
1144
1840
|
if (branchStatus === 'submitted' || branchStatus === 'approved') {
|
|
1145
|
-
|
|
1841
|
+
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
1146
1842
|
await this.pollMergeState(branchDir, branchPath, metaFile);
|
|
1147
1843
|
continue;
|
|
1148
1844
|
}
|
|
1149
1845
|
if (branchStatus === 'archived') {
|
|
1150
|
-
|
|
1846
|
+
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
1151
1847
|
continue;
|
|
1152
1848
|
}
|
|
1153
1849
|
const branchGit = simpleGit({
|
|
@@ -1159,201 +1855,336 @@ export class CmsWorker {
|
|
|
1159
1855
|
config: ['core.editor=true'],
|
|
1160
1856
|
unsafe: { allowUnsafeEditor: true },
|
|
1161
1857
|
});
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1858
|
+
// [SYNC-C1] Take the branch's cross-host content-write lock BEFORE the
|
|
1859
|
+
// dirty check, and hold it for the whole rebase.
|
|
1860
|
+
//
|
|
1861
|
+
// The dirty check alone is check-then-act. The old comment here claimed
|
|
1862
|
+
// the residual window was safe ("the rebase will fail and the catch
|
|
1863
|
+
// block will abort safely"), which only holds for a save landing before
|
|
1864
|
+
// `git rebase` STARTS. After that -- a window spanning fetch, replay and
|
|
1865
|
+
// N conflict rounds of awaited git subprocesses on EFS -- a save is
|
|
1866
|
+
// destroyed two ways: `checkout --theirs` below overwrites the
|
|
1867
|
+
// just-saved file with the branch's committed version and the rebase
|
|
1868
|
+
// then SUCCEEDS (nothing logs a failure at all), and `rebase --abort`
|
|
1869
|
+
// hard-resets the tree. The editor already got its 200 either way.
|
|
1870
|
+
//
|
|
1871
|
+
// Zero-retry acquisition, deliberately: on contention this branch is
|
|
1872
|
+
// skipped and retried on the next sync cycle (~5 min), which is the
|
|
1873
|
+
// same principle as the skip-dirty-branches behavior below. Writers get
|
|
1874
|
+
// the patient side of the asymmetry -- see utils/content-write-lock.ts.
|
|
1875
|
+
//
|
|
1876
|
+
// The heartbeat that keeps this lock fresh (proper-lockfile refreshes
|
|
1877
|
+
// every `stale`/2 = 15s) is a timer on this event loop; every git step
|
|
1878
|
+
// below is an awaited subprocess, never a synchronous block, so the
|
|
1879
|
+
// refresh keeps firing for the whole hold.
|
|
1880
|
+
let releaseContentLock;
|
|
1881
|
+
try {
|
|
1882
|
+
releaseContentLock = await tryAcquireContentWriteLock(branchPath);
|
|
1171
1883
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
//
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1197
|
-
const
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
|
|
1206
|
-
if (
|
|
1884
|
+
catch (lockErr) {
|
|
1885
|
+
if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
|
|
1886
|
+
workerLog(` Skipping ${branchDir}: content write in progress (retrying next cycle)`);
|
|
1887
|
+
skippedLocked.push(branchDir);
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
// Anything else (ENOENT on a branch dir deleted mid-cycle, EACCES,
|
|
1891
|
+
// ...) is a real failure: let the outer catch record it.
|
|
1892
|
+
throw lockErr;
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
// Skip dirty branches — editor has unsaved changes that can't be rebased.
|
|
1896
|
+
// Now inside the lock, so no write can land between this check and the
|
|
1897
|
+
// rebase below.
|
|
1898
|
+
const dirtyCheck = await branchGit.status();
|
|
1899
|
+
if (dirtyCheck.files.length > 0) {
|
|
1900
|
+
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
1901
|
+
skippedDirty.push(branchDir);
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
// The clone's own ref name: branchDir is the sanitized DIRECTORY
|
|
1905
|
+
// name and need not match it. A literal 'HEAD' means a detached
|
|
1906
|
+
// clone (e.g. a crashed rebase left one behind) -- nothing below can
|
|
1907
|
+
// safely name a ref then, so every publish path stays disarmed,
|
|
1908
|
+
// which is the safe direction.
|
|
1909
|
+
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
1910
|
+
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
1911
|
+
// [SYNC-H1] Self-heal: finish an interrupted publish even when this
|
|
1912
|
+
// branch is not behind base. Every crash window in the arming
|
|
1913
|
+
// sequence below leaves the marker set with the work unfinished, and
|
|
1914
|
+
// this is what completes it -- without it, one lost lease race would
|
|
1915
|
+
// strand the branch until the base branch happened to advance again.
|
|
1916
|
+
// Gated on the loop-top snapshot, so unmarked branches (nearly all
|
|
1917
|
+
// of them, every cycle) cost nothing extra.
|
|
1918
|
+
if (canPublish && metaFile?.branch.historyRewrittenFrom) {
|
|
1919
|
+
await this.reconcilePendingRewrite({
|
|
1920
|
+
branchPath,
|
|
1921
|
+
branchDir,
|
|
1922
|
+
branchRef,
|
|
1923
|
+
headSha: (await branchGit.revparse(['HEAD'])).trim(),
|
|
1924
|
+
marker: metaFile.branch.historyRewrittenFrom,
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
await branchGit.fetch('origin', this.baseBranch);
|
|
1928
|
+
// Use rev-list instead of status.behind — status.behind only works when the
|
|
1929
|
+
// branch has an upstream tracking branch configured, which isn't guaranteed
|
|
1930
|
+
// (checkoutBranch fallback paths create branches without --track).
|
|
1931
|
+
// The just-fetched tip, not origin/<base>: branch clones are
|
|
1932
|
+
// --single-branch, so no remote-tracking ref exists for a base branch
|
|
1933
|
+
// other than the one they were cloned from (see the base-refresh
|
|
1934
|
+
// comment above). Pinned to a SHA immediately — FETCH_HEAD is one
|
|
1935
|
+
// shared mutable file per repo, repointed by any concurrent fetch.
|
|
1936
|
+
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
1937
|
+
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
1938
|
+
const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
|
|
1939
|
+
if (behindCount === 0) {
|
|
1940
|
+
// Already in sync. This is the overwhelmingly common outcome per
|
|
1941
|
+
// branch per cycle (most branches are caught up most of the
|
|
1942
|
+
// time), so skip the save entirely when metadata already reflects
|
|
1943
|
+
// a clean state -- every save() now eager-regenerates the branch
|
|
1944
|
+
// registry (branch-metadata.ts's invalidateRegistry(), O(branch
|
|
1945
|
+
// count) fs reads on EFS), so an unconditional save here turns
|
|
1946
|
+
// every rebase cycle into O(N^2) registry work across N branches
|
|
1947
|
+
// for what is otherwise a true no-op. Re-load fresh (not the
|
|
1948
|
+
// `metaFile` snapshot from before the fetch/rev-list above) so a
|
|
1949
|
+
// concurrent editor-driven metadata change during that window
|
|
1950
|
+
// isn't clobbered by a stale skip decision.
|
|
1951
|
+
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1952
|
+
const conflictStatus = currentMeta?.branch.conflictStatus;
|
|
1953
|
+
const conflictFiles = currentMeta?.branch.conflictFiles;
|
|
1954
|
+
const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
|
|
1955
|
+
(conflictFiles === undefined || conflictFiles.length === 0);
|
|
1956
|
+
// PR-W2: a lingering rebaseFailure must also be cleared once the
|
|
1957
|
+
// branch catches up clean -- otherwise it sticks as a stale
|
|
1958
|
+
// warning forever (nothing else touches this branch once it's
|
|
1959
|
+
// caught up, so no other save site would ever clear it).
|
|
1960
|
+
const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
|
|
1961
|
+
if (alreadyClean) {
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
await meta.save({
|
|
1965
|
+
branch: {
|
|
1966
|
+
name: branchDir,
|
|
1967
|
+
conflictStatus: 'clean',
|
|
1968
|
+
conflictFiles: [],
|
|
1969
|
+
rebaseFailure: undefined,
|
|
1970
|
+
},
|
|
1971
|
+
});
|
|
1207
1972
|
continue;
|
|
1208
1973
|
}
|
|
1974
|
+
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
1975
|
+
// Read BOTH sides before rewriting anything: the arming guard after
|
|
1976
|
+
// the rebase compares what remote.git published against what this
|
|
1977
|
+
// clone is about to rebase away. Reading them afterwards would be
|
|
1978
|
+
// useless -- the clone's pre-rebase tip is exactly what disappears.
|
|
1979
|
+
const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
|
|
1980
|
+
const publishedSha = canPublish ? await this.readPublishedSha(branchRef) : null;
|
|
1981
|
+
// Resolve-and-continue loop: keep branch version for conflicting files, then continue
|
|
1982
|
+
// Non-conflicting files get main's changes; conflicting files keep branch version.
|
|
1983
|
+
const conflictedFiles = [];
|
|
1984
|
+
let nextAction = 'start';
|
|
1985
|
+
let completed = false;
|
|
1986
|
+
// PR-W1: captured only on the "unexpected error" exit below, for the
|
|
1987
|
+
// failed-summary entry pushed at the `if (!completed)` check.
|
|
1988
|
+
let failureReason;
|
|
1989
|
+
const MAX_ROUNDS = 50; // safety limit against infinite loops
|
|
1990
|
+
for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
|
|
1991
|
+
try {
|
|
1992
|
+
if (nextAction === 'start') {
|
|
1993
|
+
// The pinned base tip fetched above (single-branch clones have
|
|
1994
|
+
// no origin/<base> remote-tracking ref for other branches).
|
|
1995
|
+
await branchGit.rebase([fetchedBaseTip]);
|
|
1996
|
+
}
|
|
1997
|
+
else if (nextAction === 'continue') {
|
|
1998
|
+
await branchGit.rebase(['--continue']);
|
|
1999
|
+
}
|
|
2000
|
+
else {
|
|
2001
|
+
await branchGit.rebase(['--skip']);
|
|
2002
|
+
}
|
|
2003
|
+
completed = true;
|
|
2004
|
+
}
|
|
2005
|
+
catch (rebaseErr) {
|
|
2006
|
+
nextAction = 'continue';
|
|
2007
|
+
const st = await branchGit.status();
|
|
2008
|
+
if (st.conflicted.length > 0) {
|
|
2009
|
+
await this.afterConflictDetectedForTesting();
|
|
2010
|
+
// During rebase, --theirs = the branch being replayed (editor's work).
|
|
2011
|
+
// (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
|
|
2012
|
+
for (const file of st.conflicted) {
|
|
2013
|
+
await branchGit.raw(['checkout', '--theirs', file]);
|
|
2014
|
+
await branchGit.add(file);
|
|
2015
|
+
conflictedFiles.push(file);
|
|
2016
|
+
}
|
|
2017
|
+
// nextAction stays 'continue'
|
|
2018
|
+
}
|
|
2019
|
+
else {
|
|
2020
|
+
const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
|
|
2021
|
+
if (msg.toLowerCase().includes('nothing to commit') ||
|
|
2022
|
+
msg.toLowerCase().includes('apply --skip')) {
|
|
2023
|
+
// Empty commit after --theirs resolution — skip it
|
|
2024
|
+
nextAction = 'skip';
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
// Unexpected error — abort and leave branch behind.
|
|
2028
|
+
// We intentionally don't update conflictStatus/conflictFiles here:
|
|
2029
|
+
// the rebase didn't complete so we can't determine the true conflict
|
|
2030
|
+
// state. Previous metadata (possibly stale) is preserved until the
|
|
2031
|
+
// next successful rebase cycle corrects it.
|
|
2032
|
+
workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
2033
|
+
failureReason = msg || 'Unknown error';
|
|
2034
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
2035
|
+
break;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
if (!completed) {
|
|
2041
|
+
// PR-W2 (M1 rider): failureReason is only set on the "unexpected
|
|
2042
|
+
// error" break above -- MAX_ROUNDS exhaustion is a distinct exit
|
|
2043
|
+
// path with no error message of its own, so the warn text must
|
|
2044
|
+
// not conflate the two.
|
|
2045
|
+
workerLogWarn(failureReason !== undefined
|
|
2046
|
+
? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
|
|
2047
|
+
: ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
|
|
2048
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
2049
|
+
const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_ROUNDS} rounds`;
|
|
2050
|
+
// [HIGH-1] failed[] folds into worker-status.json's
|
|
2051
|
+
// lastGitSync.failed, served to the browser -- failureReason can
|
|
2052
|
+
// be an arbitrary git error message that embeds the bot token.
|
|
2053
|
+
const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
|
|
2054
|
+
failed.push({
|
|
2055
|
+
branch: branchDir,
|
|
2056
|
+
error: redactedRebaseFailureMessage,
|
|
2057
|
+
});
|
|
2058
|
+
// PR-W2: record once here for the "!completed" exit -- the
|
|
2059
|
+
// unexpected-error break above is NOT disjoint from this block (it
|
|
2060
|
+
// always falls through here), so recording at the break itself
|
|
2061
|
+
// would double-record. The outer catch below is the only other
|
|
2062
|
+
// record site (a distinct, non-overlapping failure class: errors
|
|
2063
|
+
// outside this round loop, e.g. fetch/rev-list failures).
|
|
2064
|
+
await this.recordRebaseFailure(branchPath, branchDir, redactedRebaseFailureMessage);
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
2068
|
+
// ID indexes rooted here stale so lookups rebuild from disk, in this
|
|
2069
|
+
// process and (via the on-disk generation marker) in the Lambda
|
|
2070
|
+
// containers sharing this filesystem.
|
|
2071
|
+
await invalidateBranchContentCaches(branchPath);
|
|
2072
|
+
// Convert file paths to ContentIds — immutable, survives slug renames.
|
|
2073
|
+
// Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
|
|
2074
|
+
// .collection.json files have no ID themselves (extractIdFromFilename returns null
|
|
2075
|
+
// for dot-prefixed files), so we extract the ID from the parent directory instead.
|
|
2076
|
+
// The root content directory (e.g., "content/", or "cms/content/" for a
|
|
2077
|
+
// multi-segment contentRoot) has no embedded ID, so we use ROOT_COLLECTION_ID
|
|
2078
|
+
// as a sentinel — but only for the configured contentRoot.
|
|
2079
|
+
//
|
|
2080
|
+
// Two different notions of "parent" are needed below, and conflating them
|
|
2081
|
+
// reproduces the exact bug this comparison guards against (see
|
|
2082
|
+
// schema-store.ts's contentRootName doc comment for the same shape elsewhere):
|
|
2083
|
+
// - `parentDir` (a basename) recovers a SUB-collection's own embedded ID
|
|
2084
|
+
// (e.g. "posts.cNbR5xFm2Kpd" -> "cNbR5xFm2Kpd") — correct as a basename,
|
|
2085
|
+
// since a collection directory carries its ID in its own name, one path
|
|
2086
|
+
// segment.
|
|
2087
|
+
// - `parentPath` (the full relative parent path, normalized) is what must be
|
|
2088
|
+
// compared against `this.contentRoot`, because `contentRoot` is documented
|
|
2089
|
+
// (config/helpers.ts) as allowed to span multiple segments (e.g.
|
|
2090
|
+
// "cms/content"). Comparing a basename ("content") against that full value
|
|
2091
|
+
// is always false, which silently drops the root collection's conflict.
|
|
2092
|
+
// Git reports POSIX-style paths and the configured value may be authored
|
|
2093
|
+
// with either separator, so both sides go through normalizeFilesystemPath
|
|
2094
|
+
// before comparing.
|
|
2095
|
+
const normalizedContentRoot = normalizeFilesystemPath(this.contentRoot);
|
|
2096
|
+
const conflictIds = [...new Set(conflictedFiles)]
|
|
2097
|
+
.map((f) => {
|
|
2098
|
+
const fileId = extractIdFromFilename(path.basename(f));
|
|
2099
|
+
if (fileId)
|
|
2100
|
+
return fileId;
|
|
2101
|
+
const parentDir = path.basename(path.dirname(f));
|
|
2102
|
+
const dirId = extractIdFromFilename(parentDir);
|
|
2103
|
+
if (dirId)
|
|
2104
|
+
return dirId;
|
|
2105
|
+
// Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
|
|
2106
|
+
// configured content root. Other unrecognized paths are filtered out.
|
|
2107
|
+
const parentPath = normalizeFilesystemPath(path.dirname(f));
|
|
2108
|
+
if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
|
|
2109
|
+
return ROOT_COLLECTION_ID;
|
|
2110
|
+
}
|
|
2111
|
+
return null;
|
|
2112
|
+
})
|
|
2113
|
+
.filter((id) => id !== null);
|
|
2114
|
+
const conflictIdsDeduped = [...new Set(conflictIds)];
|
|
2115
|
+
const hadConflicts = conflictIdsDeduped.length > 0;
|
|
2116
|
+
workerLog(hadConflicts
|
|
2117
|
+
? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
|
|
2118
|
+
: ` Rebased ${branchDir} successfully`);
|
|
1209
2119
|
await meta.save({
|
|
1210
2120
|
branch: {
|
|
1211
2121
|
name: branchDir,
|
|
1212
|
-
conflictStatus: 'clean',
|
|
1213
|
-
conflictFiles:
|
|
2122
|
+
conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
|
|
2123
|
+
conflictFiles: conflictIdsDeduped,
|
|
2124
|
+
// PR-W2: the cycle completed successfully -- clear any prior
|
|
2125
|
+
// failure record regardless of conflict outcome.
|
|
1214
2126
|
rebaseFailure: undefined,
|
|
1215
2127
|
},
|
|
1216
2128
|
});
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
// The
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
await
|
|
1253
|
-
conflictedFiles.push(file);
|
|
2129
|
+
// PR-W1: the branch was behind and the rebase completed (with or
|
|
2130
|
+
// without --theirs conflict resolution) -- it moved, so it belongs
|
|
2131
|
+
// in the summary. Branches already up to date `continue`d above and
|
|
2132
|
+
// are deliberately not listed here.
|
|
2133
|
+
rebased.push(branchDir);
|
|
2134
|
+
// [SYNC-H1] The rebase just rewrote this clone's history. If that
|
|
2135
|
+
// history was already published, nothing else will ever reconcile
|
|
2136
|
+
// remote.git (and GitHub) with it -- the editor's next submit would
|
|
2137
|
+
// simply be rejected non-fast-forward. Carry the rewrite forward.
|
|
2138
|
+
if (publishedSha !== null) {
|
|
2139
|
+
if (publishedSha === preRebaseHead) {
|
|
2140
|
+
// ARMING GUARD. remote.git holds EXACTLY what this clone just
|
|
2141
|
+
// rebased away and nothing more, so a lease keyed to it can only
|
|
2142
|
+
// undo our own rewrite.
|
|
2143
|
+
//
|
|
2144
|
+
// The inequality case below is not defensive padding: branch
|
|
2145
|
+
// clones never fetch their own branch (GitManager's clone is
|
|
2146
|
+
// --single-branch and checkoutBranch only checks out an existing
|
|
2147
|
+
// local branch), while reconcileTrackedBranches fast-forwards
|
|
2148
|
+
// remote.git to GitHub's tip. So after a reviewer pushes a fixup
|
|
2149
|
+
// straight to the PR branch, remote.git legitimately holds a
|
|
2150
|
+
// commit this clone has never seen. Leasing on "whatever
|
|
2151
|
+
// remote.git currently holds" would be SATISFIED there and would
|
|
2152
|
+
// delete that fixup from remote.git and then from GitHub,
|
|
2153
|
+
// silently. Keying the lease to the pre-rebase tip turns that
|
|
2154
|
+
// case into the visible divergence it should be.
|
|
2155
|
+
//
|
|
2156
|
+
// Order matters: mark, then push, then queue. A crash after any
|
|
2157
|
+
// step leaves the marker set with the work unfinished, which
|
|
2158
|
+
// reconcilePendingRewrite() completes on a later cycle. Pushing
|
|
2159
|
+
// first would leave remote.git rewritten, GitHub stale and
|
|
2160
|
+
// nothing recorded -- unrecoverable, and landing on exactly the
|
|
2161
|
+
// false "another deployment" diagnosis this change removes.
|
|
2162
|
+
await this.markHistoryRewritten(branchPath, branchDir, publishedSha);
|
|
2163
|
+
if (await this.forcePublishToLocalRemote(branchPath, branchRef, publishedSha)) {
|
|
2164
|
+
await this.enqueueGitHubPush(branchRef);
|
|
1254
2165
|
}
|
|
1255
|
-
// nextAction stays 'continue'
|
|
1256
2166
|
}
|
|
1257
2167
|
else {
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
}
|
|
1264
|
-
else {
|
|
1265
|
-
// Unexpected error — abort and leave branch behind.
|
|
1266
|
-
// We intentionally don't update conflictStatus/conflictFiles here:
|
|
1267
|
-
// the rebase didn't complete so we can't determine the true conflict
|
|
1268
|
-
// state. Previous metadata (possibly stale) is preserved until the
|
|
1269
|
-
// next successful rebase cycle corrects it.
|
|
1270
|
-
console.warn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
1271
|
-
failureReason = msg || 'Unknown error';
|
|
1272
|
-
await branchGit.rebase(['--abort']).catch(() => { });
|
|
1273
|
-
break;
|
|
1274
|
-
}
|
|
2168
|
+
const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
|
|
2169
|
+
`clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
|
|
2170
|
+
`before submitting again.`;
|
|
2171
|
+
workerLogWarn(` ${branchDir}: ${divergence}`);
|
|
2172
|
+
await this.recordRebaseFailure(branchPath, branchDir, divergence);
|
|
1275
2173
|
}
|
|
1276
2174
|
}
|
|
1277
2175
|
}
|
|
1278
|
-
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
|
|
1285
|
-
: ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
|
|
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,
|
|
2176
|
+
finally {
|
|
2177
|
+
// [SYNC-C1] Released on EVERY exit -- the `continue`s above, a throw
|
|
2178
|
+
// into the outer catch, and the happy path alike. A stranded lock
|
|
2179
|
+
// would wedge every write to this branch until it went stale.
|
|
2180
|
+
await releaseContentLock?.().catch((releaseErr) => {
|
|
2181
|
+
workerLogWarn(` Failed to release content-write lock for ${branchDir}: ${getErrorMessage(releaseErr)}`);
|
|
1295
2182
|
});
|
|
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);
|
|
1303
|
-
continue;
|
|
1304
2183
|
}
|
|
1305
|
-
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
1306
|
-
// ID indexes rooted here stale so lookups rebuild from disk, in this
|
|
1307
|
-
// process and (via the on-disk generation marker) in the Lambda
|
|
1308
|
-
// containers sharing this filesystem.
|
|
1309
|
-
await invalidateBranchContentCaches(branchPath);
|
|
1310
|
-
// Convert file paths to ContentIds — immutable, survives slug renames.
|
|
1311
|
-
// Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
|
|
1312
|
-
// .collection.json files have no ID themselves (extractIdFromFilename returns null
|
|
1313
|
-
// for dot-prefixed files), so we extract the ID from the parent directory instead.
|
|
1314
|
-
// The root content directory (e.g., "content/") has no embedded ID, so we use
|
|
1315
|
-
// ROOT_COLLECTION_ID as a sentinel — but only for the configured contentRoot.
|
|
1316
|
-
const conflictIds = [...new Set(conflictedFiles)]
|
|
1317
|
-
.map((f) => {
|
|
1318
|
-
const fileId = extractIdFromFilename(path.basename(f));
|
|
1319
|
-
if (fileId)
|
|
1320
|
-
return fileId;
|
|
1321
|
-
const parentDir = path.basename(path.dirname(f));
|
|
1322
|
-
const dirId = extractIdFromFilename(parentDir);
|
|
1323
|
-
if (dirId)
|
|
1324
|
-
return dirId;
|
|
1325
|
-
// Only assign ROOT_COLLECTION_ID when the parent matches the configured
|
|
1326
|
-
// content root (e.g., "content"). Other unrecognized paths are filtered out.
|
|
1327
|
-
if (path.basename(f) === '.collection.json' && parentDir === this.contentRoot) {
|
|
1328
|
-
return ROOT_COLLECTION_ID;
|
|
1329
|
-
}
|
|
1330
|
-
return null;
|
|
1331
|
-
})
|
|
1332
|
-
.filter((id) => id !== null);
|
|
1333
|
-
const conflictIdsDeduped = [...new Set(conflictIds)];
|
|
1334
|
-
const hadConflicts = conflictIdsDeduped.length > 0;
|
|
1335
|
-
console.log(hadConflicts
|
|
1336
|
-
? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
|
|
1337
|
-
: ` Rebased ${branchDir} successfully`);
|
|
1338
|
-
await meta.save({
|
|
1339
|
-
branch: {
|
|
1340
|
-
name: branchDir,
|
|
1341
|
-
conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
|
|
1342
|
-
conflictFiles: conflictIdsDeduped,
|
|
1343
|
-
// PR-W2: the cycle completed successfully -- clear any prior
|
|
1344
|
-
// failure record regardless of conflict outcome.
|
|
1345
|
-
rebaseFailure: undefined,
|
|
1346
|
-
},
|
|
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);
|
|
1353
2184
|
}
|
|
1354
2185
|
catch (err) {
|
|
1355
2186
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1356
|
-
|
|
2187
|
+
workerLogWarn(` Failed to sync ${branchDir}: ${message}`);
|
|
1357
2188
|
// [HIGH-1] Same rationale as the `if (!completed)` push site above --
|
|
1358
2189
|
// this catches fetch/rev-list/unexpected errors, whose message can
|
|
1359
2190
|
// embed the bot token.
|
|
@@ -1365,19 +2196,19 @@ export class CmsWorker {
|
|
|
1365
2196
|
await this.recordRebaseFailure(branchPath, branchDir, redactedMessage);
|
|
1366
2197
|
}
|
|
1367
2198
|
}
|
|
1368
|
-
return { rebased, skippedDirty, failed };
|
|
2199
|
+
return { rebased, skippedDirty, skippedLocked, failed };
|
|
1369
2200
|
}
|
|
1370
2201
|
async refreshAuthCache() {
|
|
1371
2202
|
if (!this.running || !this.config.refreshAuthCache)
|
|
1372
2203
|
return;
|
|
1373
|
-
|
|
2204
|
+
workerLog('Refreshing auth cache...');
|
|
1374
2205
|
try {
|
|
1375
2206
|
await this.config.refreshAuthCache();
|
|
1376
|
-
|
|
2207
|
+
workerLog('Auth cache refreshed');
|
|
1377
2208
|
}
|
|
1378
2209
|
catch (err) {
|
|
1379
2210
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1380
|
-
|
|
2211
|
+
workerLogError('Failed to refresh auth cache:', message);
|
|
1381
2212
|
}
|
|
1382
2213
|
}
|
|
1383
2214
|
}
|