canopycms 0.0.68-int.98 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2131 -0
- package/README.md +47 -63
- package/dist/ai/generate.d.ts +5 -0
- package/dist/ai/generate.js +244 -115
- package/dist/ai/index.d.ts +1 -1
- package/dist/ai/json-to-markdown.d.ts +21 -1
- package/dist/ai/json-to-markdown.js +120 -57
- package/dist/ai/references.d.ts +72 -0
- package/dist/ai/references.js +223 -0
- package/dist/ai/resolve-branch.js +9 -2
- package/dist/ai/to-plain-text.d.ts +17 -8
- package/dist/ai/to-plain-text.js +18 -15
- package/dist/ai/types.d.ts +84 -3
- package/dist/api/admin-branch-health.d.ts +24 -1
- package/dist/api/admin-branch-health.js +52 -20
- package/dist/api/admin.d.ts +23 -5
- package/dist/api/admin.js +36 -1
- package/dist/api/assets.js +88 -78
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +16 -7
- package/dist/api/branch-status.d.ts +16 -6
- package/dist/api/branch-status.js +57 -12
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +8 -3
- package/dist/api/branch.d.ts +25 -12
- package/dist/api/branch.js +164 -103
- package/dist/api/client.d.ts +78 -58
- package/dist/api/client.js +150 -117
- package/dist/api/comments.d.ts +12 -3
- package/dist/api/comments.js +118 -9
- package/dist/api/content.d.ts +9 -9
- package/dist/api/content.js +131 -22
- package/dist/api/entries-constants.d.ts +14 -3
- package/dist/api/entries-constants.js +14 -3
- package/dist/api/entries.d.ts +40 -2
- package/dist/api/entries.js +102 -6
- package/dist/api/github-sync.d.ts +19 -0
- package/dist/api/github-sync.js +50 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/groups.js +2 -1
- package/dist/api/guards.js +23 -3
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +1 -0
- package/dist/api/permissions.d.ts +21 -2
- package/dist/api/permissions.js +50 -3
- package/dist/api/request-deadline.d.ts +59 -0
- package/dist/api/request-deadline.js +73 -0
- package/dist/api/resolve-references.js +8 -7
- package/dist/api/route-builder.d.ts +14 -0
- package/dist/api/route-builder.js +14 -1
- package/dist/api/schema.d.ts +11 -9
- package/dist/api/schema.js +2 -0
- package/dist/api/settings-helpers.d.ts +5 -1
- package/dist/api/settings-helpers.js +10 -2
- package/dist/api/types.d.ts +18 -0
- package/dist/api/users-constants.d.ts +6 -0
- package/dist/api/users-constants.js +6 -0
- package/dist/assets/asset-url.d.ts +28 -16
- package/dist/assets/asset-url.js +51 -25
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/cache.d.ts +1 -1
- package/dist/auth/cache.js +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +2 -0
- package/dist/auth/caching-auth-plugin.js +12 -2
- package/dist/auth/file-based-auth-cache.d.ts +8 -0
- package/dist/auth/file-based-auth-cache.js +18 -8
- package/dist/auth/person-name.d.ts +5 -0
- package/dist/auth/person-name.js +8 -0
- package/dist/auth/plugin.d.ts +10 -1
- package/dist/auth/types.d.ts +2 -0
- package/dist/auth/user-metadata-lookup.d.ts +21 -0
- package/dist/auth/user-metadata-lookup.js +116 -0
- package/dist/authorization/branch.d.ts +1 -1
- package/dist/authorization/branch.js +12 -13
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/helpers.d.ts +8 -0
- package/dist/authorization/helpers.js +8 -0
- package/dist/authorization/index.d.ts +1 -1
- package/dist/authorization/index.js +1 -1
- package/dist/authorization/protected-branch.d.ts +5 -4
- package/dist/authorization/protected-branch.js +7 -5
- package/dist/branch-health.d.ts +35 -8
- package/dist/branch-health.js +54 -18
- package/dist/branch-metadata-error.d.ts +27 -0
- package/dist/branch-metadata-error.js +23 -0
- package/dist/branch-metadata-file.d.ts +205 -24
- package/dist/branch-metadata-file.js +126 -25
- package/dist/branch-metadata.d.ts +45 -6
- package/dist/branch-metadata.js +142 -50
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-registry.d.ts +8 -0
- package/dist/branch-registry.js +61 -9
- package/dist/branch-schema-cache.d.ts +29 -12
- package/dist/branch-schema-cache.js +64 -21
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +239 -100
- package/dist/build/asset-refs.d.ts +94 -0
- package/dist/build/asset-refs.js +267 -0
- package/dist/build/generate-ai-content.js +5 -0
- package/dist/build/inline-flight.d.ts +37 -0
- package/dist/build/inline-flight.js +142 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +8543 -3193
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +4244 -1072
- package/dist/cli/init.js +217 -126
- package/dist/cli/project-detect.d.ts +2 -0
- package/dist/cli/project-detect.js +2 -0
- package/dist/cli/template-files/Dockerfile.cms.template +13 -5
- package/dist/cli/template-files/cms-stack.ts.template +16 -13
- package/dist/client.d.ts +1 -2
- package/dist/client.js +1 -2
- package/dist/comment-store.d.ts +25 -0
- package/dist/comment-store.js +43 -8
- package/dist/config/flatten.js +2 -0
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +12 -29
- package/dist/config/schemas/config.js +9 -12
- package/dist/config/schemas/field.d.ts +3 -0
- package/dist/config/schemas/field.js +58 -4
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +2 -20
- package/dist/config/schemas/url.js +2 -25
- package/dist/config/types.d.ts +116 -22
- package/dist/config/types.js +3 -9
- package/dist/config/validation.d.ts +25 -4
- package/dist/config/validation.js +116 -3
- package/dist/content-id-index.js +2 -2
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.d.ts +10 -1
- package/dist/content-listing.js +22 -5
- package/dist/content-reader.d.ts +9 -0
- package/dist/content-reader.js +16 -11
- package/dist/content-store.d.ts +75 -12
- package/dist/content-store.js +249 -86
- package/dist/content-tree.js +2 -2
- package/dist/context.js +5 -2
- package/dist/dangling-reference-log.d.ts +17 -0
- package/dist/dangling-reference-log.js +29 -0
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +23 -7
- package/dist/editor/BranchManager.js +119 -54
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.d.ts +14 -0
- package/dist/editor/CommentsPanel.js +68 -43
- package/dist/editor/Editor.d.ts +11 -5
- package/dist/editor/Editor.js +891 -218
- package/dist/editor/EditorAuthGate.d.ts +7 -1
- package/dist/editor/EditorAuthGate.js +41 -9
- package/dist/editor/EntryNavigator.d.ts +22 -0
- package/dist/editor/EntryNavigator.js +353 -105
- package/dist/editor/FormHeader.d.ts +25 -0
- package/dist/editor/FormHeader.js +82 -0
- package/dist/editor/FormRenderer.d.ts +27 -3
- package/dist/editor/FormRenderer.js +171 -76
- package/dist/editor/PreviewFrame.d.ts +40 -0
- package/dist/editor/PreviewFrame.js +215 -0
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
- package/dist/editor/admin/SystemHealthPanel.js +163 -33
- package/dist/editor/admin/useSystemHealth.d.ts +18 -0
- package/dist/editor/admin/useSystemHealth.js +70 -1
- package/dist/editor/branch-status.d.ts +17 -0
- package/dist/editor/branch-status.js +29 -0
- package/dist/editor/canopy-path.d.ts +9 -6
- package/dist/editor/canopy-path.js +8 -11
- package/dist/editor/client-reference-resolver.d.ts +43 -16
- package/dist/editor/client-reference-resolver.js +155 -64
- package/dist/editor/comments/BranchComments.d.ts +1 -1
- package/dist/editor/comments/BranchComments.js +2 -2
- package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
- package/dist/editor/comments/CloseRefusedNotice.js +7 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
- package/dist/editor/comments/CommentThreadsPopover.js +274 -0
- package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
- package/dist/editor/comments/CommentWriteAlert.js +13 -0
- package/dist/editor/comments/EntryComments.d.ts +10 -4
- package/dist/editor/comments/EntryComments.js +14 -17
- package/dist/editor/comments/FieldWrapper.d.ts +18 -4
- package/dist/editor/comments/FieldWrapper.js +62 -28
- package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
- package/dist/editor/comments/InlineCommentThread.js +11 -47
- package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
- package/dist/editor/comments/ThreadCarousel.js +18 -21
- package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
- package/dist/editor/comments/ThreadReplyControls.js +92 -0
- package/dist/editor/comments/field-comment-context.d.ts +14 -0
- package/dist/editor/comments/field-comment-context.js +14 -0
- package/dist/editor/comments/form-comments.d.ts +48 -0
- package/dist/editor/comments/form-comments.js +178 -0
- package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
- package/dist/editor/comments/item-anchor-copy.js +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
- package/dist/editor/comments/popover-placement.d.ts +18 -0
- package/dist/editor/comments/popover-placement.js +58 -0
- package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
- package/dist/editor/comments/use-tracked-writes.js +53 -0
- package/dist/editor/components/BranchAccessList.d.ts +15 -0
- package/dist/editor/components/BranchAccessList.js +52 -0
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/BranchesDrawer.d.ts +11 -0
- package/dist/editor/components/BranchesDrawer.js +5 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +6 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
- package/dist/editor/components/DestructiveMenuItem.js +8 -0
- package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
- package/dist/editor/components/EditorCrashScreen.js +22 -0
- package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
- package/dist/editor/components/EditorErrorBoundary.js +40 -0
- package/dist/editor/components/EditorHeader.d.ts +56 -106
- package/dist/editor/components/EditorHeader.js +130 -131
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/EntryCreateModal.d.ts +12 -7
- package/dist/editor/components/EntryCreateModal.js +51 -57
- package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
- package/dist/editor/components/NoEditPermissionNotice.js +5 -4
- package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
- package/dist/editor/components/QuickOpenPalette.js +66 -0
- package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
- package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
- package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
- package/dist/editor/components/ReasonedMenuItem.js +15 -0
- package/dist/editor/components/ReferencedByList.d.ts +11 -0
- package/dist/editor/components/ReferencedByList.js +28 -0
- package/dist/editor/components/RelativeTime.d.ts +10 -0
- package/dist/editor/components/RelativeTime.js +10 -0
- package/dist/editor/components/RenameEntryModal.d.ts +9 -1
- package/dist/editor/components/RenameEntryModal.js +19 -31
- package/dist/editor/components/RetryToastMessage.d.ts +9 -0
- package/dist/editor/components/RetryToastMessage.js +6 -0
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
- package/dist/editor/components/StagedChangesDrawer.js +92 -0
- package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
- package/dist/editor/components/UnavailableEntryNotice.js +9 -0
- package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
- package/dist/editor/components/UnavailableTypeMessage.js +9 -0
- package/dist/editor/components/UndoToastMessage.d.ts +18 -0
- package/dist/editor/components/UndoToastMessage.js +10 -0
- package/dist/editor/components/UserBadge.d.ts +2 -5
- package/dist/editor/components/UserBadge.js +62 -33
- package/dist/editor/components/ValidationSummary.d.ts +14 -0
- package/dist/editor/components/ValidationSummary.js +24 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/ApiClientContext.d.ts +8 -2
- package/dist/editor/context/ApiClientContext.js +19 -8
- package/dist/editor/context/AssetContext.d.ts +13 -35
- package/dist/editor/context/AssetContext.js +12 -31
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/context/SWRProvider.d.ts +3 -3
- package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
- package/dist/editor/context/SiteMdxAllowContext.js +7 -0
- package/dist/editor/context/index.d.ts +2 -1
- package/dist/editor/context/index.js +2 -1
- package/dist/editor/contrast.d.ts +19 -0
- package/dist/editor/contrast.js +41 -0
- package/dist/editor/copy.d.ts +18 -0
- package/dist/editor/copy.js +18 -0
- package/dist/editor/editor-config.js +16 -0
- package/dist/editor/editor-utils.d.ts +34 -8
- package/dist/editor/editor-utils.js +42 -20
- package/dist/editor/entry-search.d.ts +20 -0
- package/dist/editor/entry-search.js +34 -0
- package/dist/editor/field-breadcrumb.d.ts +12 -0
- package/dist/editor/field-breadcrumb.js +85 -0
- package/dist/editor/field-props.d.ts +86 -0
- package/dist/editor/field-props.js +27 -0
- package/dist/editor/field-target.d.ts +30 -0
- package/dist/editor/field-target.js +77 -0
- package/dist/editor/fields/BlockField.d.ts +12 -0
- package/dist/editor/fields/BlockField.js +89 -73
- package/dist/editor/fields/CodeField.d.ts +2 -0
- package/dist/editor/fields/CodeField.js +6 -2
- package/dist/editor/fields/DateTimeField.d.ts +4 -29
- package/dist/editor/fields/DateTimeField.js +79 -41
- package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
- package/dist/editor/fields/ExpandAllButton.js +4 -0
- package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
- package/dist/editor/fields/FieldCrashFallback.js +30 -0
- package/dist/editor/fields/FieldLabel.d.ts +37 -0
- package/dist/editor/fields/FieldLabel.js +19 -0
- package/dist/editor/fields/ImageField.d.ts +4 -0
- package/dist/editor/fields/ImageField.js +29 -14
- package/dist/editor/fields/InlineGroupField.js +4 -3
- package/dist/editor/fields/ItemList.d.ts +42 -0
- package/dist/editor/fields/ItemList.js +130 -0
- package/dist/editor/fields/MarkdownField.d.ts +32 -0
- package/dist/editor/fields/MarkdownField.js +223 -46
- package/dist/editor/fields/MdxImageDialog.js +1 -1
- package/dist/editor/fields/NumberField.d.ts +2 -0
- package/dist/editor/fields/NumberField.js +8 -5
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +19 -16
- package/dist/editor/fields/ObjectField.d.ts +2 -7
- package/dist/editor/fields/ObjectField.js +15 -5
- package/dist/editor/fields/ObjectListField.d.ts +29 -0
- package/dist/editor/fields/ObjectListField.js +57 -0
- package/dist/editor/fields/ReferenceField.d.ts +2 -0
- package/dist/editor/fields/ReferenceField.js +34 -9
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +11 -2
- package/dist/editor/fields/StringListField.d.ts +4 -1
- package/dist/editor/fields/StringListField.js +22 -8
- package/dist/editor/fields/TextField.d.ts +2 -0
- package/dist/editor/fields/TextField.js +6 -2
- package/dist/editor/fields/ToggleField.d.ts +2 -0
- package/dist/editor/fields/ToggleField.js +8 -3
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
- package/dist/editor/fields/form-expansion.d.ts +9 -0
- package/dist/editor/fields/form-expansion.js +2 -0
- package/dist/editor/fields/item-expansion.d.ts +15 -0
- package/dist/editor/fields/item-expansion.js +60 -0
- package/dist/editor/fields/list-item-keys.d.ts +8 -0
- package/dist/editor/fields/list-item-keys.js +50 -0
- package/dist/editor/fields/list-item-title.d.ts +20 -0
- package/dist/editor/fields/list-item-title.js +85 -0
- package/dist/editor/fields/markdown-export-options.d.ts +2 -0
- package/dist/editor/fields/markdown-export-options.js +8 -0
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
- package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
- package/dist/editor/fields/mdx-jsx-support.d.ts +5 -1
- package/dist/editor/fields/mdx-jsx-support.js +157 -69
- package/dist/editor/fields/rich-text-failures.d.ts +5 -0
- package/dist/editor/fields/rich-text-failures.js +26 -0
- package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
- package/dist/editor/fields/toolbar-wrap.js +35 -0
- package/dist/editor/form-outline.d.ts +13 -0
- package/dist/editor/form-outline.js +70 -0
- package/dist/editor/group-manager/GroupCard.d.ts +4 -1
- package/dist/editor/group-manager/GroupCard.js +12 -3
- package/dist/editor/group-manager/GroupForm.js +1 -1
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
- package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
- package/dist/editor/group-manager/MemberList.d.ts +2 -1
- package/dist/editor/group-manager/MemberList.js +15 -4
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
- package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
- package/dist/editor/group-manager/index.js +34 -5
- package/dist/editor/group-manager/types.d.ts +5 -2
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +54 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useBranchActions.d.ts +11 -1
- package/dist/editor/hooks/useBranchActions.js +42 -19
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +321 -125
- package/dist/editor/hooks/useBranchesData.d.ts +8 -4
- package/dist/editor/hooks/useBranchesData.js +9 -5
- package/dist/editor/hooks/useCommentSystem.d.ts +14 -2
- package/dist/editor/hooks/useCommentSystem.js +79 -55
- package/dist/editor/hooks/useCommentsData.d.ts +11 -7
- package/dist/editor/hooks/useCommentsData.js +20 -19
- package/dist/editor/hooks/useDraftManager.d.ts +77 -3
- package/dist/editor/hooks/useDraftManager.js +887 -94
- package/dist/editor/hooks/useEditorLayout.d.ts +53 -7
- package/dist/editor/hooks/useEditorLayout.js +98 -20
- package/dist/editor/hooks/useEntriesData.d.ts +2 -1
- package/dist/editor/hooks/useEntriesData.js +18 -4
- package/dist/editor/hooks/useEntryManager.d.ts +41 -2
- package/dist/editor/hooks/useEntryManager.js +227 -41
- package/dist/editor/hooks/useGroupManager.js +13 -16
- package/dist/editor/hooks/usePermissionManager.js +14 -6
- package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
- package/dist/editor/hooks/usePreviewMarks.js +33 -0
- package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
- package/dist/editor/hooks/useReferenceResolution.js +34 -177
- package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
- package/dist/editor/hooks/useSchemaManager.js +97 -39
- package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
- package/dist/editor/hooks/useUserMetadata.js +76 -47
- package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
- package/dist/editor/hooks/user-metadata-batcher.js +33 -0
- package/dist/editor/media/AssetCard.d.ts +6 -7
- package/dist/editor/media/AssetCard.js +24 -31
- package/dist/editor/media/AssetListRow.d.ts +16 -0
- package/dist/editor/media/AssetListRow.js +41 -0
- package/dist/editor/media/CropStep.js +6 -1
- package/dist/editor/media/MediaLibrary.d.ts +4 -3
- package/dist/editor/media/MediaLibrary.js +11 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
- package/dist/editor/media/MediaLibraryBody.js +195 -80
- package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
- package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +8 -0
- package/dist/editor/media/editor-image-src.js +10 -0
- package/dist/editor/media/format-bytes.d.ts +2 -0
- package/dist/editor/media/format-bytes.js +23 -0
- package/dist/editor/media/media-library-prefs.d.ts +23 -0
- package/dist/editor/media/media-library-prefs.js +49 -0
- package/dist/editor/media/media-library-view.d.ts +34 -0
- package/dist/editor/media/media-library-view.js +40 -0
- package/dist/editor/media/useAssetLibrary.d.ts +30 -0
- package/dist/editor/media/useAssetLibrary.js +126 -0
- package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
- package/dist/editor/media/useStableAssetOrder.js +37 -0
- package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
- package/dist/editor/media/useThumbnailFailure.js +16 -0
- package/dist/editor/permission-manager/GroupSelector.js +2 -2
- package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
- package/dist/editor/permission-manager/PermissionEditor.js +15 -4
- package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
- package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.js +3 -3
- package/dist/editor/permission-manager/index.js +12 -3
- package/dist/editor/permission-manager/types.d.ts +5 -2
- package/dist/editor/platform.d.ts +3 -0
- package/dist/editor/platform.js +9 -0
- package/dist/editor/preview-asset-base.d.ts +9 -0
- package/dist/editor/preview-asset-base.js +17 -0
- package/dist/editor/preview-bridge.d.ts +51 -27
- package/dist/editor/preview-bridge.js +103 -144
- package/dist/editor/preview-marks.d.ts +15 -0
- package/dist/editor/preview-marks.js +74 -0
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/raw-asset-base.d.ts +5 -0
- package/dist/editor/raw-asset-base.js +14 -0
- package/dist/editor/relative-time.d.ts +4 -4
- package/dist/editor/relative-time.js +16 -8
- package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
- package/dist/editor/schema-editor/CollectionEditor.js +361 -62
- package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
- package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
- package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
- package/dist/editor/schema-editor/collection-draft.js +40 -0
- package/dist/editor/schema-editor/index.d.ts +1 -1
- package/dist/editor/schema-label.d.ts +10 -0
- package/dist/editor/schema-label.js +17 -0
- package/dist/editor/settings-conflict.d.ts +5 -0
- package/dist/editor/settings-conflict.js +8 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/slug.d.ts +16 -0
- package/dist/editor/slug.js +43 -0
- package/dist/editor/theme.d.ts +2 -1
- package/dist/editor/theme.js +314 -13
- package/dist/editor/unanswered.d.ts +17 -0
- package/dist/editor/unanswered.js +26 -0
- package/dist/editor/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -0
- package/dist/editor/undo-removal.d.ts +57 -0
- package/dist/editor/undo-removal.js +163 -0
- package/dist/editor/user-display.d.ts +4 -0
- package/dist/editor/user-display.js +5 -0
- package/dist/editor/utils/confirm-modal.d.ts +8 -0
- package/dist/editor/utils/confirm-modal.js +12 -0
- package/dist/editor/utils/editor-errors.d.ts +19 -0
- package/dist/editor/utils/editor-errors.js +32 -0
- package/dist/editor/utils/focus-return.d.ts +56 -0
- package/dist/editor/utils/focus-return.js +106 -0
- package/dist/entry-link-resolver.d.ts +11 -1
- package/dist/entry-link-resolver.js +65 -5
- package/dist/entry-schema-registry.d.ts +3 -3
- package/dist/entry-schema-registry.js +7 -4
- package/dist/entry-schema.d.ts +49 -3
- package/dist/entry-schema.js +18 -0
- package/dist/git-manager.d.ts +172 -10
- package/dist/git-manager.js +449 -107
- package/dist/github-service.d.ts +39 -2
- package/dist/github-service.js +101 -7
- package/dist/http/handler.js +74 -9
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +9 -4
- package/dist/http/worker-not-ready.js +59 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/item-keys.d.ts +46 -0
- package/dist/item-keys.js +235 -0
- package/dist/operating-mode/editor-mode-check.d.ts +15 -0
- package/dist/operating-mode/editor-mode-check.js +24 -0
- package/dist/operating-mode/mode-env.d.ts +13 -14
- package/dist/operating-mode/mode-env.js +13 -14
- package/dist/operating-mode/types.d.ts +2 -2
- package/dist/paths/branch.d.ts +0 -1
- package/dist/paths/branch.js +1 -8
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.js +1 -0
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/meta-loader.d.ts +18 -8
- package/dist/schema/meta-loader.js +42 -9
- package/dist/schema/registry-fingerprint.d.ts +14 -0
- package/dist/schema/registry-fingerprint.js +63 -0
- package/dist/schema/resolver.d.ts +7 -4
- package/dist/schema/resolver.js +7 -6
- package/dist/schema/schema-store.d.ts +7 -10
- package/dist/schema/schema-store.js +13 -11
- package/dist/schema/schema-unavailable-error.d.ts +11 -0
- package/dist/schema/schema-unavailable-error.js +15 -0
- package/dist/schema/types.d.ts +22 -0
- package/dist/schema-registry-record.d.ts +35 -0
- package/dist/schema-registry-record.js +134 -0
- package/dist/server.d.ts +79 -9
- package/dist/server.js +62 -8
- package/dist/services.d.ts +33 -5
- package/dist/services.js +155 -37
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +159 -45
- package/dist/stamp-on-publish.d.ts +36 -0
- package/dist/stamp-on-publish.js +71 -0
- package/dist/static/index.d.ts +58 -5
- package/dist/static/index.js +133 -3
- package/dist/static/seo.d.ts +5 -5
- package/dist/static/seo.js +5 -5
- package/dist/submission-attribution.d.ts +8 -0
- package/dist/submission-attribution.js +22 -0
- package/dist/system-users.d.ts +12 -0
- package/dist/system-users.js +12 -0
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/task-queue/cms-task-queue.js +1 -1
- package/dist/task-queue/index.d.ts +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/task-queue.d.ts +6 -0
- package/dist/task-queue/task-queue.js +29 -0
- package/dist/task-queue/worker-status.d.ts +35 -9
- package/dist/task-queue/worker-status.js +84 -17
- package/dist/types.d.ts +90 -9
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +8 -1
- package/dist/url-exclusivity-fixtures.js +11 -1
- package/dist/user.js +2 -1
- package/dist/utils/atomic-write.js +2 -1
- package/dist/utils/base-branch.d.ts +13 -0
- package/dist/utils/base-branch.js +23 -0
- package/dist/utils/content-serialize.d.ts +15 -7
- package/dist/utils/content-serialize.js +353 -51
- package/dist/utils/content-write-lock.d.ts +6 -1
- package/dist/utils/content-write-lock.js +21 -6
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/error.js +13 -2
- package/dist/utils/escape-text.d.ts +7 -0
- package/dist/utils/escape-text.js +27 -0
- package/dist/utils/fs.d.ts +12 -4
- package/dist/utils/fs.js +18 -5
- package/dist/utils/git.d.ts +21 -1
- package/dist/utils/git.js +63 -8
- package/dist/utils/json-source-splice.d.ts +14 -0
- package/dist/utils/json-source-splice.js +120 -0
- package/dist/utils/markdown-body-splice.d.ts +39 -0
- package/dist/utils/markdown-body-splice.js +421 -0
- package/dist/utils/occ-json-write.js +18 -12
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +32 -1
- package/dist/utils/provisioning-lock.js +78 -32
- package/dist/utils/sanitize-href.d.ts +9 -23
- package/dist/utils/sanitize-href.js +14 -28
- package/dist/utils/title-field.d.ts +10 -0
- package/dist/utils/title-field.js +47 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/validation/deletion-checker.d.ts +21 -29
- package/dist/validation/deletion-checker.js +41 -117
- package/dist/validation/entry-validator.d.ts +6 -10
- package/dist/validation/entry-validator.js +19 -26
- package/dist/validation/field-traversal.d.ts +22 -0
- package/dist/validation/field-traversal.js +39 -8
- package/dist/validation/markdown-safety.d.ts +68 -0
- package/dist/validation/markdown-safety.js +596 -0
- package/dist/validation/mdx-allowlist.d.ts +55 -0
- package/dist/validation/mdx-allowlist.js +183 -0
- package/dist/validation/reference-validator.d.ts +2 -0
- package/dist/validation/reference-validator.js +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +124 -0
- package/dist/worker/branch-policy.js +243 -0
- package/dist/worker/bundle-header.d.ts +117 -0
- package/dist/worker/bundle-header.js +253 -0
- package/dist/worker/cms-worker.d.ts +236 -142
- package/dist/worker/cms-worker.js +772 -285
- package/dist/worker/gateway-egress.d.ts +43 -0
- package/dist/worker/gateway-egress.js +209 -0
- package/dist/worker/gateway-instance.d.ts +42 -0
- package/dist/worker/gateway-instance.js +129 -0
- package/dist/worker/git-process.d.ts +81 -0
- package/dist/worker/git-process.js +234 -0
- package/dist/worker/git-sync.d.ts +34 -17
- package/dist/worker/git-sync.js +394 -131
- package/dist/worker/github-auth.d.ts +9 -9
- package/dist/worker/github-auth.js +13 -15
- package/dist/worker/github-gateway-http.d.ts +62 -0
- package/dist/worker/github-gateway-http.js +977 -0
- package/dist/worker/github-gateway-wire.d.ts +590 -0
- package/dist/worker/github-gateway-wire.js +488 -0
- package/dist/worker/github-gateway.d.ts +238 -0
- package/dist/worker/github-gateway.js +726 -0
- package/dist/worker/github-mirror-upkeep.d.ts +75 -0
- package/dist/worker/github-mirror-upkeep.js +266 -0
- package/dist/worker/github-mirror.d.ts +364 -0
- package/dist/worker/github-mirror.js +1030 -0
- package/dist/worker/github-transfer.d.ts +56 -0
- package/dist/worker/github-transfer.js +444 -0
- package/dist/worker/history-rewrite.d.ts +4 -5
- package/dist/worker/history-rewrite.js +12 -17
- package/dist/worker/provisioned-workspace.d.ts +5 -5
- package/dist/worker/provisioned-workspace.js +5 -5
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +58 -54
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +12 -0
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/self-checks.d.ts +107 -0
- package/dist/worker/self-checks.js +358 -0
- package/dist/worker/shared-repo-git.d.ts +96 -0
- package/dist/worker/shared-repo-git.js +459 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +136 -0
- package/dist/worker/stale-mirror.d.ts +82 -0
- package/dist/worker/stale-mirror.js +130 -0
- package/dist/worker/task-runner.d.ts +5 -3
- package/dist/worker/task-runner.js +287 -186
- package/dist/worker/worker-context.d.ts +36 -37
- package/package.json +35 -7
- package/dist/editor/EditorPanes.d.ts +0 -10
- package/dist/editor/EditorPanes.js +0 -98
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -67
- package/dist/editor/components/EditorSidebar.js +0 -22
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import { simpleGit } from 'simple-git';
|
|
4
5
|
import lockfile from 'proper-lockfile';
|
|
5
6
|
import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { resolveWorkerGitHubAuth } from './github-auth.js';
|
|
8
|
+
import { seedFromGitHub, unreachableFrom } from './github-transfer.js';
|
|
9
|
+
import { createLocalGitHubGateway, } from './github-gateway.js';
|
|
10
|
+
import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
|
|
11
|
+
import { readHeadBranch } from '../utils/git.js';
|
|
12
|
+
import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
|
|
9
13
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
10
14
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
11
|
-
import {
|
|
15
|
+
import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
|
|
12
16
|
import { CANOPYCMS_VERSION } from '../version.js';
|
|
13
17
|
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
18
|
+
import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.js';
|
|
19
|
+
import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
|
|
14
20
|
import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
|
|
15
21
|
import { pollMergeState, runRebaseCycle } from './rebase.js';
|
|
16
22
|
import { cleanupTrashedBranchDirs, pushSettingsBranches, refreshBaseBranchWorkspace, syncGit, } from './git-sync.js';
|
|
@@ -26,38 +32,229 @@ export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from '.
|
|
|
26
32
|
// App builds the credential itself (core must not import `@octokit/auth-app` —
|
|
27
33
|
// see github-auth.ts) and needs the shape to inject and the key normalizer to
|
|
28
34
|
// apply.
|
|
29
|
-
export {
|
|
30
|
-
|
|
35
|
+
export {
|
|
36
|
+
// The gateway process resolves its credential from its own config.
|
|
37
|
+
resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, } from './github-auth.js';
|
|
38
|
+
// Re-exported for the same reason: an entrypoint that runs the GitHub gateway and the worker as
|
|
39
|
+
// two processes serves the in-process gateway on a socket in one, and connects to it in the other.
|
|
40
|
+
export { createLocalGitHubGateway, } from './github-gateway.js';
|
|
41
|
+
export { serveGitHubGateway, connectGitHubGateway, } from './github-gateway-http.js';
|
|
42
|
+
export { GitHubGatewayUnreachableError } from './github-gateway-wire.js';
|
|
43
|
+
// A gateway that failed to boot still serves the instance operations.
|
|
44
|
+
export { GatewayInstance } from './gateway-instance.js';
|
|
45
|
+
// The gateway process's scrubbed log lines, and the exact scrubs for the secrets it holds that
|
|
46
|
+
// are not GitHub tokens (the auth provider's key, the App's private key). `redactEgress` scrubs
|
|
47
|
+
// the cause a failed boot hands the worker.
|
|
48
|
+
export { gatewayLog, gatewayLogWarn, gatewayLogError, registerSecretText, registerPrivateKey, redactEgress, } from './gateway-egress.js';
|
|
49
|
+
// The boot self-checks the split entrypoint runs in each role (.claude/future-tasks/resolved/
|
|
50
|
+
// worker-shared-repo-git-process-split.md §5).
|
|
51
|
+
export { assertSelfChecks, gatewaySelfChecks, workerSelfChecks, } from './self-checks.js';
|
|
52
|
+
/** Per workspace, so two workers on one host (tests, dev) never share a mirror. */
|
|
53
|
+
function defaultStateDirectory(workspacePath) {
|
|
54
|
+
const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
|
|
55
|
+
return path.join(os.tmpdir(), `canopycms-worker-${key}`);
|
|
56
|
+
}
|
|
57
|
+
/** How many refs a refusal to replace remote.git names before summarising the rest. */
|
|
58
|
+
const MAX_REFS_LISTED = 20;
|
|
59
|
+
/** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
|
|
60
|
+
class RemoteGitKeptError extends Error {
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
|
|
64
|
+
* holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
|
|
65
|
+
* a replacement would discard unchecked, so it throws when git reports anything on stderr
|
|
66
|
+
* (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
|
|
67
|
+
* by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
|
|
68
|
+
* skipped without a warning, and names nothing to lose.
|
|
69
|
+
*/
|
|
70
|
+
async function listRemoteGitRefs(gitDir) {
|
|
71
|
+
let stderr = '';
|
|
72
|
+
const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
|
|
73
|
+
.outputHandler((_command, _stdout, err) => {
|
|
74
|
+
err.on('data', (chunk) => {
|
|
75
|
+
stderr += String(chunk);
|
|
76
|
+
});
|
|
77
|
+
})
|
|
78
|
+
.raw(['for-each-ref', '--format=%(objectname) %(refname)']);
|
|
79
|
+
if (stderr.trim() !== '')
|
|
80
|
+
throw new Error(stderr.trim());
|
|
81
|
+
const refs = new Map();
|
|
82
|
+
for (const line of output.split('\n')) {
|
|
83
|
+
const space = line.indexOf(' ');
|
|
84
|
+
if (space === -1)
|
|
85
|
+
continue;
|
|
86
|
+
const ref = line.slice(space + 1);
|
|
87
|
+
if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
|
|
88
|
+
refs.set(ref, line.slice(0, space));
|
|
89
|
+
}
|
|
90
|
+
return refs;
|
|
91
|
+
}
|
|
92
|
+
function sameRefs(a, b) {
|
|
93
|
+
return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The default `taskTimeoutMs`. Exported because a split worker's gateway client passes it as its
|
|
97
|
+
* `fetchInactivityMs` and the gateway uses it as its `timeoutMs`, as the in-process gateway does.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
31
100
|
const DEFAULT_MAX_RETRIES = 3;
|
|
32
101
|
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
33
102
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
103
|
+
* 90s fits inside an EC2 spot interruption's two-minute notice with room to
|
|
104
|
+
* exit, and exceeds the default task timeout, so a task already running when
|
|
105
|
+
* the drain begins normally finishes or times out on its own.
|
|
106
|
+
*/
|
|
107
|
+
export const DEFAULT_DRAIN_DEADLINE_MS = 90_000;
|
|
108
|
+
/**
|
|
109
|
+
* After aborting at the drain deadline, how long to let the aborted work
|
|
110
|
+
* unwind: this, or the deadline itself if that is shorter.
|
|
111
|
+
*/
|
|
112
|
+
const ABORT_GRACE_MS = 5_000;
|
|
113
|
+
/** Whether every promise settles within `ms`. */
|
|
114
|
+
async function settlesWithin(operations, ms) {
|
|
115
|
+
let timer;
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([
|
|
118
|
+
Promise.allSettled(operations).then(() => true),
|
|
119
|
+
new Promise((resolve) => {
|
|
120
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Shown as the last shutdown's reason in System health, so it reads as a sentence there.
|
|
129
|
+
const LOCK_COMPROMISED_REASON = 'the worker lost its lock on the shared workspace';
|
|
130
|
+
/**
|
|
131
|
+
* Take the cross-host worker lock (see {@link CmsWorker}'s `acquireLock`), retrying for up to
|
|
132
|
+
* `waitMs`. Rejects with ELOCKED while another holder keeps it fresh.
|
|
133
|
+
*/
|
|
134
|
+
function lockWorkerTaskDir(taskDir, lockStaleMs, options) {
|
|
135
|
+
const waitMs = options.waitMs ?? 0;
|
|
136
|
+
const intervalMs = Math.max(250, Math.min(5_000, Math.floor(lockStaleMs / 4)));
|
|
137
|
+
return lockfile.lock(taskDir, {
|
|
138
|
+
lockfilePath: path.join(taskDir, '.worker-lock'),
|
|
139
|
+
stale: lockStaleMs,
|
|
140
|
+
onCompromised: options.onCompromised,
|
|
141
|
+
...(waitMs > 0
|
|
142
|
+
? {
|
|
143
|
+
retries: {
|
|
144
|
+
retries: Math.ceil(waitMs / intervalMs),
|
|
145
|
+
factor: 1,
|
|
146
|
+
minTimeout: intervalMs,
|
|
147
|
+
maxTimeout: intervalMs,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The fields a new status snapshot carries from the previous file: `lastFatalError` and
|
|
155
|
+
* `lastShutdown` (task-queue/worker-status.ts) and the schema gate's hold, so its bound keeps
|
|
156
|
+
* counting from the first worker that saw each schema missing.
|
|
157
|
+
*/
|
|
158
|
+
async function readCarriedFields(taskDir) {
|
|
159
|
+
const { lastFatalError, lastShutdown } = await readCarriedOverStatus(taskDir);
|
|
160
|
+
const baseHold = await readCarriedBaseHold(taskDir);
|
|
161
|
+
return {
|
|
162
|
+
...(lastFatalError ? { lastFatalError } : {}),
|
|
163
|
+
...(lastShutdown ? { lastShutdown } : {}),
|
|
164
|
+
...(baseHold ? { baseHold } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Record a worker that failed before {@link CmsWorker.start} as `lastFatalError` (phase
|
|
169
|
+
* `startup`), so System health and the API's not-ready answer say why. For an entrypoint's own
|
|
170
|
+
* boot steps (its environment, its secrets); start() records its own failures.
|
|
38
171
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
172
|
+
* Writes under the worker lock like every status write, and writes nothing while the lock looks
|
|
173
|
+
* held (fresher than twice `lockStaleMs`, see recordLockLoss): its holder owns the file. Never
|
|
174
|
+
* throws, since its caller is already exiting.
|
|
41
175
|
*/
|
|
176
|
+
export async function recordWorkerStartupFailure(options) {
|
|
177
|
+
const taskDir = path.join(options.workspacePath, '.tasks');
|
|
178
|
+
let release;
|
|
179
|
+
try {
|
|
180
|
+
await fs.mkdir(taskDir, { recursive: true });
|
|
181
|
+
// Twice the usual staleness, for the reason recordLockLoss gives: a lost record costs
|
|
182
|
+
// nothing, a removed live lock costs a worker.
|
|
183
|
+
release = await lockWorkerTaskDir(taskDir, 2 * (options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS), {
|
|
184
|
+
onCompromised: (err) => workerLogError('Worker lock lost while recording a startup failure:', getErrorMessage(err)),
|
|
185
|
+
});
|
|
186
|
+
const now = new Date().toISOString();
|
|
187
|
+
const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(taskDir);
|
|
188
|
+
if (lastFatalError &&
|
|
189
|
+
options.keepRecordedFatal &&
|
|
190
|
+
lastFatalError.workerStartedAt === (await readWorkerStatusStartedAt(taskDir)) &&
|
|
191
|
+
options.keepRecordedFatal(lastFatalError)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await writeWorkerStatus(taskDir, {
|
|
195
|
+
version: 1,
|
|
196
|
+
workerVersion: CANOPYCMS_VERSION,
|
|
197
|
+
startedAt: now,
|
|
198
|
+
updatedAt: now,
|
|
199
|
+
...(lastShutdown ? { lastShutdown } : {}),
|
|
200
|
+
...(baseHold ? { baseHold } : {}),
|
|
201
|
+
lastFatalError: {
|
|
202
|
+
// [REDACT] Served to the browser by the admin panel and the API's not-ready answer.
|
|
203
|
+
message: redactCredentials(getErrorMessage(options.error)),
|
|
204
|
+
at: now,
|
|
205
|
+
phase: 'startup',
|
|
206
|
+
workerStartedAt: now,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
workerLogError(isNodeError(err) && err.code === 'ELOCKED'
|
|
212
|
+
? 'Not recording the startup failure: another worker holds the lock and owns worker-status.json'
|
|
213
|
+
: `Failed to record the startup failure: ${getErrorMessage(err)}`);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await release?.().catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
42
219
|
export class CmsWorker {
|
|
43
220
|
constructor(config) {
|
|
44
221
|
this.config = config;
|
|
45
222
|
this.activeTimeouts = new Set();
|
|
46
223
|
this.running = false;
|
|
47
|
-
|
|
224
|
+
// In-flight loop iterations, each labelled for the drain's log lines and
|
|
225
|
+
// `lastShutdown.abandoned`.
|
|
226
|
+
this.activeOperations = new Map();
|
|
227
|
+
this.stopping = null;
|
|
228
|
+
// Aborted when a draining stop() reaches its deadline; see WorkerContext.
|
|
229
|
+
this.shutdownController = new AbortController();
|
|
48
230
|
this.releaseLockFn = null;
|
|
49
231
|
this.log = cmsTaskQueueLogger;
|
|
232
|
+
this.warnedDivergences = new Map();
|
|
233
|
+
/**
|
|
234
|
+
* Settles once the worker has stopped for a reason it chose itself (its lock was
|
|
235
|
+
* compromised), after the drain and its record in worker-status.json. Never settles for a
|
|
236
|
+
* stop() the entrypoint asked for. Nothing else ends the process, so an entrypoint exits
|
|
237
|
+
* non-zero on it and its process manager starts a fresh worker.
|
|
238
|
+
*/
|
|
239
|
+
this.selfStopped = new Promise((resolve) => {
|
|
240
|
+
this.settleSelfStopped = resolve;
|
|
241
|
+
});
|
|
50
242
|
this.taskDir = path.join(config.workspacePath, '.tasks');
|
|
51
|
-
|
|
243
|
+
// Absolute: git can read a relative path such as `sub/remote.git` as a remote's name and use
|
|
244
|
+
// that remote's `remote.<name>.url` from a clone's config; never one that starts with '/'.
|
|
245
|
+
this.remoteGitPath = path.join(path.resolve(config.workspacePath), 'remote.git');
|
|
246
|
+
this.stateDirectory = path.resolve(config.stateDirectory ?? defaultStateDirectory(config.workspacePath));
|
|
52
247
|
this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
|
|
53
|
-
|
|
54
|
-
|
|
248
|
+
if (config.baseBranch !== undefined)
|
|
249
|
+
this.setBaseBranch(config.baseBranch);
|
|
55
250
|
this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
|
|
56
251
|
this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
|
|
57
252
|
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
58
253
|
this.lockFilePath = path.join(config.workspacePath, '.tasks', '.worker-lock');
|
|
59
254
|
this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
255
|
+
this.drainDeadlineMs = config.drainDeadlineMs ?? DEFAULT_DRAIN_DEADLINE_MS;
|
|
60
256
|
this.contentRoot = config.contentRoot ?? 'content';
|
|
257
|
+
this.schemaHoldMaxMs = config.schemaHoldMaxMs ?? DEFAULT_SCHEMA_HOLD_MAX_MS;
|
|
61
258
|
}
|
|
62
259
|
/**
|
|
63
260
|
* This worker's self-reported status object, initialized on first use.
|
|
@@ -89,12 +286,12 @@ export class CmsWorker {
|
|
|
89
286
|
* as foreign and never pushes it.
|
|
90
287
|
*
|
|
91
288
|
* DEFERRED out of the constructor deliberately, which is the whole point of
|
|
92
|
-
* the method existing:
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
289
|
+
* the method existing: an entrypoint constructs the worker before calling
|
|
290
|
+
* start(), so a throw during `new CmsWorker(...)` lands BEFORE start()'s
|
|
291
|
+
* catch, which records `lastFatalError` for every entrypoint. Only one that
|
|
292
|
+
* calls `recordWorkerStartupFailure` (canopycms-cdk/worker/run.ts does) would
|
|
293
|
+
* record it otherwise; under systemd `Restart=always` the rest crash-loop
|
|
294
|
+
* every ~5s with System health showing the worker 'absent' and no reason.
|
|
98
295
|
*
|
|
99
296
|
* Lazy rather than start()-only so unit tests driving pushSettingsBranches()
|
|
100
297
|
* still see the value, exactly as ensureStatusReport() above. Idempotent: the
|
|
@@ -115,56 +312,87 @@ export class CmsWorker {
|
|
|
115
312
|
* Built FRESH on every call, with instance-backed members as functions rather
|
|
116
313
|
* than copied values. See WorkerContext's INVARIANT: a context that captured
|
|
117
314
|
* any of them at construction time would hand the extracted code the pre-test
|
|
118
|
-
* value, which for
|
|
119
|
-
* for real instead of its local fixture repo.
|
|
315
|
+
* value, which for the GitHub gateway means a test's push going to
|
|
316
|
+
* github.com for real instead of its local fixture repo.
|
|
120
317
|
*/
|
|
121
318
|
ctx() {
|
|
122
319
|
return {
|
|
123
|
-
githubOwner: this.config.githubOwner,
|
|
124
|
-
githubRepo: this.config.githubRepo,
|
|
125
320
|
baseBranch: this.baseBranch,
|
|
126
321
|
sanitizedBaseBranch: this.sanitizedBaseBranch,
|
|
127
322
|
taskDir: this.taskDir,
|
|
128
323
|
remoteGitPath: this.remoteGitPath,
|
|
129
324
|
contentBranchesPath: this.contentBranchesPath,
|
|
130
325
|
contentRoot: this.contentRoot,
|
|
326
|
+
schemaHoldMaxMs: this.schemaHoldMaxMs,
|
|
131
327
|
taskTimeoutMs: this.taskTimeoutMs,
|
|
132
328
|
maxTasksPerCycle: this.maxTasksPerCycle,
|
|
133
329
|
maxRetries: this.maxRetries,
|
|
134
330
|
log: this.log,
|
|
135
|
-
|
|
136
|
-
buildGitHubUrl: () => this.buildGitHubUrl(),
|
|
137
|
-
refreshGitHubCredential: () => this.refreshGitHubCredential(),
|
|
331
|
+
github: () => this.github(),
|
|
138
332
|
branchWorkspacePath: (branchRefName) => this.branchWorkspacePath(branchRefName),
|
|
139
333
|
executeTask: (task, signal) => this.executeTask(task, signal),
|
|
140
|
-
pushBranchToGitHub: (branch) => this.pushBranchToGitHub(branch),
|
|
334
|
+
pushBranchToGitHub: (branch, signal) => this.pushBranchToGitHub(branch, signal),
|
|
141
335
|
isRunning: () => this.running,
|
|
336
|
+
isDraining: () => this.stopping !== null,
|
|
337
|
+
shutdownSignal: () => this.shutdownController.signal,
|
|
142
338
|
ensureStatusReport: () => this.ensureStatusReport(),
|
|
143
339
|
ensureSettingsBranch: () => this.ensureSettingsBranch(),
|
|
340
|
+
warnedDivergences: () => this.warnedDivergences,
|
|
144
341
|
afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
|
|
145
342
|
afterRebaseCompletedForTesting: () => this.afterRebaseCompletedForTesting(),
|
|
146
343
|
};
|
|
147
344
|
}
|
|
148
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Take the worker lock, provision, run the first sync, then schedule the
|
|
347
|
+
* loops. All of it is tracked as an operation, so a stop() that lands
|
|
348
|
+
* mid-startup -- during the lock acquisition, a first-boot clone, the first
|
|
349
|
+
* sync -- drains it before releasing the lock, and start() then resolves
|
|
350
|
+
* without scheduling anything.
|
|
351
|
+
*/
|
|
352
|
+
start() {
|
|
353
|
+
const starting = this.startUnderLock();
|
|
354
|
+
this.trackOperation('startup', starting);
|
|
355
|
+
return starting;
|
|
356
|
+
}
|
|
357
|
+
async startUnderLock() {
|
|
358
|
+
// A stop() that already finished has nothing left to release a lock taken now.
|
|
359
|
+
if (this.stopping)
|
|
360
|
+
return;
|
|
149
361
|
this.running = true;
|
|
150
362
|
workerLog('CMS Worker starting...');
|
|
363
|
+
// Each attempt is a new worker to worker-status.json: a start() retried after a failed one
|
|
364
|
+
// must not match that failure's `workerStartedAt` (see readCarriedOverStatus).
|
|
365
|
+
this.statusReport = undefined;
|
|
151
366
|
this.ensureStatusReport();
|
|
152
367
|
await this.acquireLock();
|
|
153
368
|
// Replace the previous holder's status file now: the first sync can take
|
|
154
369
|
// minutes, and until then System health would report the old worker's
|
|
155
370
|
// version. Best-effort, like the startup-failure write below. The previous
|
|
156
371
|
// `lastFatalError` rides along in this snapshot only, so a crash loop keeps
|
|
157
|
-
// its alert between restarts while the first successful sync still clears
|
|
372
|
+
// its alert between restarts while the first successful sync still clears
|
|
373
|
+
// it. Two fields carry into the report itself: `lastShutdown`, which
|
|
374
|
+
// describes the last worker that ran until this worker's own stop() replaces it,
|
|
375
|
+
// and the schema gate's hold, so its bound keeps counting from the first
|
|
376
|
+
// worker that saw each schema missing.
|
|
158
377
|
try {
|
|
159
|
-
const lastFatalError = await
|
|
378
|
+
const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(this.taskDir);
|
|
379
|
+
const report = this.ensureStatusReport();
|
|
380
|
+
if (lastShutdown)
|
|
381
|
+
report.lastShutdown = lastShutdown;
|
|
382
|
+
if (baseHold)
|
|
383
|
+
report.baseHold = baseHold;
|
|
160
384
|
await writeWorkerStatus(this.taskDir, {
|
|
161
|
-
...
|
|
385
|
+
...report,
|
|
162
386
|
...(lastFatalError ? { lastFatalError } : {}),
|
|
163
387
|
});
|
|
164
388
|
}
|
|
165
389
|
catch (err) {
|
|
166
390
|
workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
|
|
167
391
|
}
|
|
392
|
+
// After the carry-over, not before: a stop() during the lock acquisition
|
|
393
|
+
// writes this report, which must still hold what the previous worker left.
|
|
394
|
+
if (this.stopping)
|
|
395
|
+
return;
|
|
168
396
|
// Everything below runs while holding the cross-host worker lock. A failure
|
|
169
397
|
// here (most notably the empty-remote guard inside ensureRemoteGit) means
|
|
170
398
|
// the process is about to exit and systemd (Restart=always) will retry —
|
|
@@ -181,12 +409,19 @@ export class CmsWorker {
|
|
|
181
409
|
// Same shape, same reason: a half-configured credential throws HERE,
|
|
182
410
|
// inside the try, rather than out of `new CmsWorker(...)` where nothing
|
|
183
411
|
// could record it.
|
|
184
|
-
this.
|
|
412
|
+
const github = this.github();
|
|
413
|
+
// Same again. NaN would never expire a schema hold, which is the one
|
|
414
|
+
// thing the bound exists to prevent.
|
|
415
|
+
if (!Number.isFinite(this.schemaHoldMaxMs) || this.schemaHoldMaxMs < 0) {
|
|
416
|
+
throw new Error(`CmsWorker: schemaHoldMaxMs must be a finite, non-negative number of milliseconds (got ${this.schemaHoldMaxMs})`);
|
|
417
|
+
}
|
|
185
418
|
// BEFORE ensureRemoteGit(): its clone is the first thing to use the
|
|
186
419
|
// credential, and its catch blames the repository rather than the
|
|
187
|
-
// credential. See
|
|
188
|
-
await
|
|
420
|
+
// credential. See GitHubGateway.prepare().
|
|
421
|
+
await github.prepare();
|
|
422
|
+
await this.resolveBaseBranch();
|
|
189
423
|
await this.ensureRemoteGit();
|
|
424
|
+
await this.recordBaseBranchInRemoteHead();
|
|
190
425
|
// Recover orphaned tasks immediately rather than waiting for the first
|
|
191
426
|
// processTaskQueue() poll. Not the only call site: processTaskQueue()
|
|
192
427
|
// repeats this every cycle, and its doc comment says why a boot-only call
|
|
@@ -195,13 +430,22 @@ export class CmsWorker {
|
|
|
195
430
|
if (recovered > 0) {
|
|
196
431
|
workerLog(`Recovered ${recovered} orphaned task(s)`);
|
|
197
432
|
}
|
|
198
|
-
|
|
433
|
+
// Tracked like loop iterations, so a stop() during the first sync drains it.
|
|
434
|
+
const initialTasks = [
|
|
435
|
+
this.trackOperation('git sync', this.syncGit()),
|
|
436
|
+
this.trackOperation('base branch check', this.warnIfBaseUnprotected()),
|
|
437
|
+
];
|
|
199
438
|
if (this.config.refreshAuthCache) {
|
|
200
|
-
initialTasks.push(this.refreshAuthCache());
|
|
439
|
+
initialTasks.push(this.trackOperation('auth cache refresh', this.refreshAuthCache()));
|
|
201
440
|
}
|
|
202
441
|
await Promise.allSettled(initialTasks);
|
|
203
442
|
}
|
|
204
443
|
catch (err) {
|
|
444
|
+
// A drain cut startup short: stop() owns the lock and the status file now.
|
|
445
|
+
if (this.stopping) {
|
|
446
|
+
workerLog(`Startup ended by the drain: ${redactCredentials(getErrorMessage(err))}`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
205
449
|
// Surface a startup failure (e.g. the empty-remote guard's poisoned
|
|
206
450
|
// remote.git) to the admin panel via worker-status.json, not only
|
|
207
451
|
// journald/CloudWatch. Best-effort and BEFORE releaseLock(): a
|
|
@@ -210,10 +454,11 @@ export class CmsWorker {
|
|
|
210
454
|
report.lastFatalError = {
|
|
211
455
|
// [REDACT] Persisted to worker-status.json and served to the browser by
|
|
212
456
|
// the admin panel -- must never carry the bot token a poisoned or
|
|
213
|
-
// failed git URL (
|
|
457
|
+
// failed git URL (the gateway's) can embed.
|
|
214
458
|
message: redactCredentials(getErrorMessage(err)),
|
|
215
459
|
at: new Date().toISOString(),
|
|
216
460
|
phase: 'startup',
|
|
461
|
+
workerStartedAt: report.startedAt,
|
|
217
462
|
};
|
|
218
463
|
try {
|
|
219
464
|
await writeWorkerStatus(this.taskDir, report);
|
|
@@ -224,39 +469,95 @@ export class CmsWorker {
|
|
|
224
469
|
await this.releaseLock();
|
|
225
470
|
throw err;
|
|
226
471
|
}
|
|
472
|
+
if (this.stopping)
|
|
473
|
+
return;
|
|
227
474
|
const taskInterval = this.config.taskPollInterval ?? 5_000;
|
|
228
475
|
const gitInterval = this.config.gitSyncInterval ?? 5 * 60_000;
|
|
229
|
-
this.scheduleLoop(() => this.processTaskQueue(), taskInterval);
|
|
230
|
-
|
|
231
|
-
// revoked GitHub credential is noticed. start()'s own initial syncGit()
|
|
232
|
-
// above stays unwrapped -- there is no stale credential to refresh one line
|
|
233
|
-
// after reading it at boot.
|
|
234
|
-
this.scheduleLoop(() => this.syncGitWithCredentialRefresh(), gitInterval);
|
|
476
|
+
this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
|
|
477
|
+
this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
|
|
235
478
|
if (this.config.refreshAuthCache) {
|
|
236
479
|
const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
|
|
237
|
-
this.scheduleLoop(() => this.refreshAuthCache(), cacheInterval);
|
|
480
|
+
this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
|
|
238
481
|
workerLog(` Auth cache refresh: every ${cacheInterval / 1000}s`);
|
|
239
482
|
}
|
|
240
483
|
workerLog('CMS Worker started');
|
|
241
484
|
workerLog(` Task queue poll: every ${taskInterval / 1000}s`);
|
|
242
485
|
workerLog(` Git sync: every ${gitInterval / 1000}s`);
|
|
243
486
|
}
|
|
244
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Drain, then release the worker lock. Idempotent: a second call returns the
|
|
489
|
+
* first call's promise.
|
|
490
|
+
*
|
|
491
|
+
* 1. Stop taking work: no loop starts another iteration, and none claims
|
|
492
|
+
* another task or starts another sync stage or branch rebase (each checks
|
|
493
|
+
* `isDraining()` at its boundaries).
|
|
494
|
+
* 2. Wait up to the deadline (`drainDeadlineMs`, or `deadlineMs` for this
|
|
495
|
+
* call) for what is already in flight, startup included.
|
|
496
|
+
* 3. At the deadline, abort it through `shutdownSignal()`: a task's git push
|
|
497
|
+
* and GitHub calls are killed and the task is released to pending with no
|
|
498
|
+
* retry spent; a sync's bundle, unbundle, ref update and settings push
|
|
499
|
+
* are killed. The gateway's GitHub fetches and mirror maintenance run on
|
|
500
|
+
* (safeinsights/canopycms#907). A branch rebase
|
|
501
|
+
* is never aborted, because a rebase killed mid-branch is recovered
|
|
502
|
+
* lossily; anything still unsettled after a short grace is abandoned to
|
|
503
|
+
* the process exit.
|
|
504
|
+
* 4. Record `lastShutdown` in worker-status.json -- only while this worker
|
|
505
|
+
* still holds the lock, or after a compromise that started this stop once
|
|
506
|
+
* it has retaken it (recordLockLoss) -- and release the lock. Release comes last, so a successor starts
|
|
507
|
+
* only once this worker's work has settled or been abandoned.
|
|
508
|
+
*/
|
|
509
|
+
stop(options = {}) {
|
|
510
|
+
// A later call cannot restart the drain, but deadline 0 (a lock compromise)
|
|
511
|
+
// still cuts the running one short.
|
|
512
|
+
if (this.stopping && options.deadlineMs === 0)
|
|
513
|
+
this.shutdownController.abort();
|
|
514
|
+
this.stopping ??= this.drainAndStop(options.reason ?? 'stop requested', options.deadlineMs ?? this.drainDeadlineMs);
|
|
515
|
+
return this.stopping;
|
|
516
|
+
}
|
|
517
|
+
async drainAndStop(reason, deadlineMs) {
|
|
518
|
+
const startedAt = Date.now();
|
|
245
519
|
this.running = false;
|
|
246
520
|
for (const t of this.activeTimeouts) {
|
|
247
521
|
clearTimeout(t);
|
|
248
522
|
}
|
|
249
523
|
this.activeTimeouts.clear();
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
524
|
+
const inFlight = [...this.activeOperations.values()];
|
|
525
|
+
workerLog(inFlight.length > 0
|
|
526
|
+
? `Draining (${reason}): waiting up to ${deadlineMs / 1000}s for ${inFlight.join(', ')}`
|
|
527
|
+
: `Draining (${reason}): nothing in flight`);
|
|
528
|
+
let abandoned = [];
|
|
529
|
+
if (!(await settlesWithin([...this.activeOperations.keys()], deadlineMs))) {
|
|
530
|
+
abandoned = [...new Set(this.activeOperations.values())];
|
|
531
|
+
workerLogWarn(`Drain deadline (${deadlineMs / 1000}s) hit, aborting: ${abandoned.join(', ')}`);
|
|
532
|
+
this.shutdownController.abort();
|
|
533
|
+
const graceMs = Math.min(ABORT_GRACE_MS, this.drainDeadlineMs);
|
|
534
|
+
if (!(await settlesWithin([...this.activeOperations.keys()], graceMs))) {
|
|
535
|
+
workerLogError(`Still running ${graceMs / 1000}s after the abort, abandoned to the exit: ${[
|
|
536
|
+
...new Set(this.activeOperations.values()),
|
|
537
|
+
].join(', ')}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const drainMs = Date.now() - startedAt;
|
|
541
|
+
const shutdown = {
|
|
542
|
+
reason,
|
|
543
|
+
at: new Date().toISOString(),
|
|
544
|
+
workerStartedAt: this.ensureStatusReport().startedAt,
|
|
545
|
+
outcome: abandoned.length > 0 ? 'deadline' : 'drained',
|
|
546
|
+
drainMs,
|
|
547
|
+
...(abandoned.length > 0 ? { abandoned } : {}),
|
|
548
|
+
};
|
|
549
|
+
if (this.releaseLockFn) {
|
|
550
|
+
const report = this.ensureStatusReport();
|
|
551
|
+
report.lastShutdown = shutdown;
|
|
552
|
+
await writeWorkerStatus(this.taskDir, report).catch((err) => workerLogError('Failed to write worker status on shutdown:', getErrorMessage(err)));
|
|
553
|
+
}
|
|
554
|
+
else if (this.lockLostMessage !== undefined) {
|
|
555
|
+
await this.recordLockLoss(shutdown, this.lockLostMessage);
|
|
556
|
+
}
|
|
258
557
|
await this.releaseLock();
|
|
259
|
-
workerLog(
|
|
558
|
+
workerLog(abandoned.length > 0
|
|
559
|
+
? `CMS Worker stopped after the drain deadline (${(drainMs / 1000).toFixed(1)}s)`
|
|
560
|
+
: `CMS Worker stopped: drained in ${(drainMs / 1000).toFixed(1)}s`);
|
|
260
561
|
}
|
|
261
562
|
/**
|
|
262
563
|
* Acquire the cross-host worker lock (DEP-C2).
|
|
@@ -283,16 +584,22 @@ export class CmsWorker {
|
|
|
283
584
|
async acquireLock() {
|
|
284
585
|
await fs.mkdir(this.taskDir, { recursive: true });
|
|
285
586
|
try {
|
|
286
|
-
this.releaseLockFn = await
|
|
287
|
-
lockfilePath: this.lockFilePath,
|
|
288
|
-
stale: this.lockStaleMs,
|
|
587
|
+
this.releaseLockFn = await lockWorkerTaskDir(this.taskDir, this.lockStaleMs, {
|
|
289
588
|
onCompromised: (err) => {
|
|
290
589
|
// The heartbeat could not be maintained (lock deleted or taken over),
|
|
291
|
-
// so another worker may now be consuming the queue:
|
|
292
|
-
// to
|
|
590
|
+
// so another worker may now be consuming the queue: abort at once,
|
|
591
|
+
// with no drain, to restore the single-consumer invariant. Unless a
|
|
592
|
+
// stop was already under way, this worker chose to stop, which
|
|
593
|
+
// selfStopped reports once the drain has recorded it.
|
|
293
594
|
workerLogError('Worker lock compromised, shutting down:', getErrorMessage(err));
|
|
294
595
|
this.releaseLockFn = null; // the lock is already lost; nothing to release
|
|
295
|
-
|
|
596
|
+
const selfStop = this.stopping === null;
|
|
597
|
+
if (selfStop)
|
|
598
|
+
this.lockLostMessage = redactCredentials(getErrorMessage(err));
|
|
599
|
+
const stopped = this.stop({ reason: LOCK_COMPROMISED_REASON, deadlineMs: 0 });
|
|
600
|
+
if (selfStop) {
|
|
601
|
+
void stopped.then(() => this.settleSelfStopped({ reason: LOCK_COMPROMISED_REASON }));
|
|
602
|
+
}
|
|
296
603
|
},
|
|
297
604
|
});
|
|
298
605
|
}
|
|
@@ -302,6 +609,7 @@ export class CmsWorker {
|
|
|
302
609
|
}
|
|
303
610
|
throw err;
|
|
304
611
|
}
|
|
612
|
+
this.statusWriterAtLock = await readWorkerStatusStartedAt(this.taskDir).catch(() => undefined);
|
|
305
613
|
}
|
|
306
614
|
async releaseLock() {
|
|
307
615
|
const release = this.releaseLockFn;
|
|
@@ -315,12 +623,67 @@ export class CmsWorker {
|
|
|
315
623
|
// Lock already released or compromised
|
|
316
624
|
}
|
|
317
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Record a lock compromise in worker-status.json, under the lock: retake it, waiting out its
|
|
628
|
+
* staleness window, and write only if that succeeds and the file is still this worker's (or
|
|
629
|
+
* the one it took the lock from). A worker that took the lock over keeps it fresh, or has
|
|
630
|
+
* written the file since, and owns it, so nothing is written. A heartbeat this worker merely
|
|
631
|
+
* failed to refresh (an EFS hiccup) goes stale, and the record lands where the restarted
|
|
632
|
+
* worker carries `lastShutdown` forward. The restart waits for it: 65 s at the default `lockStaleMs`.
|
|
633
|
+
*/
|
|
634
|
+
async recordLockLoss(shutdown, message) {
|
|
635
|
+
let release;
|
|
636
|
+
try {
|
|
637
|
+
// Twice the usual staleness: a successor's live heartbeat can look a refresh interval
|
|
638
|
+
// plus an EFS attribute-cache window old from here, and taking it would remove its lock.
|
|
639
|
+
release = await lockWorkerTaskDir(this.taskDir, this.lockStaleMs * 2, {
|
|
640
|
+
// A refresh failure fires the compromise once this worker's own lock has gone
|
|
641
|
+
// unrefreshed for `lockStaleMs`, or up to a second less (proper-lockfile rounds the
|
|
642
|
+
// first mtime up). So the wait covers the remaining `lockStaleMs`, that second and a
|
|
643
|
+
// retry interval, and stays inside the unit's TimeoutStopSec when a SIGTERM lands
|
|
644
|
+
// mid-wait. A deleted lock is retaken at once; a foreign mtime is a successor's.
|
|
645
|
+
waitMs: this.lockStaleMs + 2_000,
|
|
646
|
+
onCompromised: (err) => workerLogError('Worker lock lost again while recording the loss:', getErrorMessage(err)),
|
|
647
|
+
});
|
|
648
|
+
const report = this.ensureStatusReport();
|
|
649
|
+
// A successor that took the lock and released it during the wait owns the file now.
|
|
650
|
+
const writtenBy = await readWorkerStatusStartedAt(this.taskDir);
|
|
651
|
+
if (writtenBy !== undefined &&
|
|
652
|
+
writtenBy !== report.startedAt &&
|
|
653
|
+
writtenBy !== this.statusWriterAtLock) {
|
|
654
|
+
workerLogError('Not recording the lock loss: another worker has written worker-status.json since');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
report.lastShutdown = shutdown;
|
|
658
|
+
report.lastFatalError = {
|
|
659
|
+
message: `The worker lost its lock on the shared workspace and stopped; it restarts on its own. ${message}`,
|
|
660
|
+
at: shutdown.at,
|
|
661
|
+
phase: 'run',
|
|
662
|
+
workerStartedAt: report.startedAt,
|
|
663
|
+
};
|
|
664
|
+
await writeWorkerStatus(this.taskDir, report);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
workerLogError(isNodeError(err) && err.code === 'ELOCKED'
|
|
668
|
+
? 'Not recording the lock loss: another worker holds the lock and owns worker-status.json'
|
|
669
|
+
: `Failed to record the lock loss: ${getErrorMessage(err)}`);
|
|
670
|
+
}
|
|
671
|
+
finally {
|
|
672
|
+
await release?.().catch(() => { });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Register `operation` as in flight under `label` until it settles. */
|
|
676
|
+
trackOperation(label, operation) {
|
|
677
|
+
this.activeOperations.set(operation, label);
|
|
678
|
+
void operation.finally(() => this.activeOperations.delete(operation)).catch(() => { });
|
|
679
|
+
return operation;
|
|
680
|
+
}
|
|
318
681
|
/**
|
|
319
682
|
* Run `fn` repeatedly, the next invocation starting `interval` ms after the
|
|
320
683
|
* previous one COMPLETES. setTimeout chaining rather than setInterval, so
|
|
321
684
|
* executions cannot overlap when one runs longer than the interval.
|
|
322
685
|
*/
|
|
323
|
-
scheduleLoop(fn, interval) {
|
|
686
|
+
scheduleLoop(label, fn, interval) {
|
|
324
687
|
const run = () => {
|
|
325
688
|
if (!this.running)
|
|
326
689
|
return;
|
|
@@ -329,22 +692,139 @@ export class CmsWorker {
|
|
|
329
692
|
const operation = fn().catch((err) => {
|
|
330
693
|
workerLogError('Worker loop error:', err instanceof Error ? err.message : err);
|
|
331
694
|
});
|
|
332
|
-
this.
|
|
333
|
-
operation.finally(() => this.activeOperations.delete(operation));
|
|
334
|
-
await operation;
|
|
695
|
+
await this.trackOperation(label, operation);
|
|
335
696
|
run();
|
|
336
697
|
}, interval);
|
|
337
698
|
this.activeTimeouts.add(timeout);
|
|
338
699
|
};
|
|
339
700
|
run();
|
|
340
701
|
}
|
|
702
|
+
get baseBranch() {
|
|
703
|
+
return this.requireBaseBranch().name;
|
|
704
|
+
}
|
|
705
|
+
get sanitizedBaseBranch() {
|
|
706
|
+
return this.requireBaseBranch().sanitized;
|
|
707
|
+
}
|
|
708
|
+
requireBaseBranch() {
|
|
709
|
+
if (!this.resolvedBaseBranch) {
|
|
710
|
+
throw new Error('CmsWorker: the base branch is resolved by start(); it has not run yet');
|
|
711
|
+
}
|
|
712
|
+
return this.resolvedBaseBranch;
|
|
713
|
+
}
|
|
714
|
+
setBaseBranch(name) {
|
|
715
|
+
this.resolvedBaseBranch = { name, sanitized: sanitizeBranchName(name) };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
|
|
719
|
+
* reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
|
|
720
|
+
* branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
|
|
721
|
+
* outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
|
|
722
|
+
* replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
|
|
723
|
+
*/
|
|
724
|
+
async resolveBaseBranch() {
|
|
725
|
+
if (this.resolvedBaseBranch)
|
|
726
|
+
return;
|
|
727
|
+
const undetermined = (err) => new Error(`CANOPYCMS_BASE_BRANCH is not set (the CDK construct's \`baseBranch\` prop), and the ` +
|
|
728
|
+
`base branch could not be determined from ${this.remoteGitPath} or GitHub: ` +
|
|
729
|
+
`${redactCredentials(getErrorMessage(err))}. Set it to the branch editing branches fork ` +
|
|
730
|
+
`from, or, if remote.git is damaged, delete ${this.remoteGitPath} and restart to re-clone.`);
|
|
731
|
+
let remoteGitExists;
|
|
732
|
+
try {
|
|
733
|
+
remoteGitExists = await fs.stat(this.remoteGitPath).then(() => true, (err) => {
|
|
734
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
735
|
+
return false;
|
|
736
|
+
throw err;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
throw undetermined(err);
|
|
741
|
+
}
|
|
742
|
+
// Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
|
|
743
|
+
// base branch to configure or a remote.git to delete.
|
|
744
|
+
if (remoteGitExists)
|
|
745
|
+
await this.assertRemoteGitConfig();
|
|
746
|
+
let name;
|
|
747
|
+
try {
|
|
748
|
+
const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
|
|
749
|
+
name = fromRemoteGit
|
|
750
|
+
? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
|
|
751
|
+
: await this.github().defaultBranch();
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
throw undetermined(err);
|
|
755
|
+
}
|
|
756
|
+
this.setBaseBranch(name);
|
|
757
|
+
workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Warn when the base branch is outside the gateway's protected set: it was detected from
|
|
761
|
+
* remote.git rather than configured, and is not GitHub's default branch at boot. Only the
|
|
762
|
+
* worker's own check (`pushBranchToGitHub`) then stops a push to it. A warning, not a refusal, so
|
|
763
|
+
* a change of GitHub's default branch cannot stop the worker booting.
|
|
764
|
+
*/
|
|
765
|
+
async warnIfBaseUnprotected() {
|
|
766
|
+
try {
|
|
767
|
+
if (await this.github().isProtectedBranch(this.baseBranch, this.shutdownController.signal)) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
workerLogWarn(`The base branch '${this.baseBranch}' is neither CANOPYCMS_BASE_BRANCH nor GitHub's ` +
|
|
771
|
+
`default branch, nor in CANOPYCMS_PROTECTED_BRANCHES, so the GitHub gateway's policy ` +
|
|
772
|
+
`does not protect it; only the worker's own check stops a push to it. Set ` +
|
|
773
|
+
`CANOPYCMS_BASE_BRANCH (the CDK construct's \`baseBranch\` prop).`);
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
workerLogWarn(`Could not check that the base branch '${this.baseBranch}' is protected: ` +
|
|
777
|
+
redactCredentials(getErrorMessage(err)));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
|
|
782
|
+
* something wrote there, and listing the refs a replacement would lose means running git under
|
|
783
|
+
* that config. The message says so, so System health does not read it as a routine failure.
|
|
784
|
+
*/
|
|
785
|
+
async assertRemoteGitConfig() {
|
|
786
|
+
try {
|
|
787
|
+
await assertSharedRepoConfig(this.remoteGitPath, 'bare');
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
if (!(err instanceof SharedRepoRefusalError))
|
|
791
|
+
throw err;
|
|
792
|
+
throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
|
|
793
|
+
`replaces a remote.git whose config was tampered with or cannot be read.`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
|
|
797
|
+
async hasContentBranch(gitDir) {
|
|
798
|
+
const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
|
|
799
|
+
'for-each-ref',
|
|
800
|
+
'--format=%(refname:strip=2)',
|
|
801
|
+
'refs/heads/',
|
|
802
|
+
]);
|
|
803
|
+
return branches
|
|
804
|
+
.split('\n')
|
|
805
|
+
.some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
|
|
809
|
+
* (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
|
|
810
|
+
* the worker lock, and in a fresh clone before it is renamed into place, so no Lambda reads the
|
|
811
|
+
* clone's GitHub-default HEAD first; a Lambda reads it once per process.
|
|
812
|
+
*/
|
|
813
|
+
async recordBaseBranchInRemoteHead(gitDir = this.remoteGitPath) {
|
|
814
|
+
const ref = `refs/heads/${this.baseBranch}`;
|
|
815
|
+
const current = await readHeadBranch(gitDir, sharedRepoGit(gitDir, 'bare')).catch(() => undefined);
|
|
816
|
+
if (current === this.baseBranch)
|
|
817
|
+
return;
|
|
818
|
+
// Pinned: moving HEAD fires remote.git's reference-transaction hooks.
|
|
819
|
+
await sharedRepoGit(gitDir, 'bare').raw(['symbolic-ref', 'HEAD', ref]);
|
|
820
|
+
workerLog(`${path.basename(gitDir)} HEAD now names the base branch '${this.baseBranch}'`);
|
|
821
|
+
}
|
|
341
822
|
/**
|
|
342
823
|
* Whether the bare repo at `gitDir` has a local `refs/heads/<baseBranch>`.
|
|
343
824
|
*
|
|
344
|
-
*
|
|
825
|
+
* The repository is named outright (sharedRepoGit's GIT_DIR), so this also
|
|
345
826
|
* works where `safe.bareRepository=explicit` refuses cwd-based discovery of
|
|
346
|
-
* bare repos
|
|
347
|
-
* GitManager.bareRemoteHasBranch).
|
|
827
|
+
* bare repos.
|
|
348
828
|
*
|
|
349
829
|
* Deliberately omits `--quiet`: simple-git treats a task as failed only when
|
|
350
830
|
* the process exits non-zero AND writes to stderr, so a silent-on-failure
|
|
@@ -353,9 +833,7 @@ export class CmsWorker {
|
|
|
353
833
|
* which is what makes simple-git reject the promise here.
|
|
354
834
|
*/
|
|
355
835
|
async verifyBaseBranchExists(gitDir) {
|
|
356
|
-
await
|
|
357
|
-
'--git-dir',
|
|
358
|
-
gitDir,
|
|
836
|
+
await sharedRepoGit(gitDir, 'bare').raw([
|
|
359
837
|
'rev-parse',
|
|
360
838
|
'--verify',
|
|
361
839
|
`refs/heads/${this.baseBranch}`,
|
|
@@ -368,8 +846,8 @@ export class CmsWorker {
|
|
|
368
846
|
* `git clone https://x-access-token:<token>@github.com/...` records that URL
|
|
369
847
|
* verbatim as `remote.origin.url`, and for `remote.git` that config lives on
|
|
370
848
|
* shared EFS. The security model in docs/deploying-to-aws.md -- a compromised
|
|
371
|
-
* Lambda can read/write EFS content but cannot
|
|
372
|
-
*
|
|
849
|
+
* Lambda can read/write EFS content but cannot reach the GitHub credential,
|
|
850
|
+
* which stays off EFS -- is false while that string is there: the Lambda could read
|
|
373
851
|
* the token off EFS and, with no egress of its own, exfiltrate it by writing
|
|
374
852
|
* it into branch content the worker then pushes to GitHub.
|
|
375
853
|
*
|
|
@@ -380,7 +858,7 @@ export class CmsWorker {
|
|
|
380
858
|
* survives, so a failed scrub is never indistinguishable from a clean one.
|
|
381
859
|
*/
|
|
382
860
|
async scrubPersistedRemote(gitDir) {
|
|
383
|
-
const git =
|
|
861
|
+
const git = sharedRepoGit(gitDir, 'bare');
|
|
384
862
|
// `git config --get` exits 1 with no output when the key is absent, and
|
|
385
863
|
// simple-git resolves with an empty string rather than throwing (verified
|
|
386
864
|
// against 3.36), so an empty result means "absent" too.
|
|
@@ -433,17 +911,31 @@ export class CmsWorker {
|
|
|
433
911
|
}
|
|
434
912
|
}
|
|
435
913
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
914
|
+
* Best-effort: without these settings remote.git still works, and a boot that
|
|
915
|
+
* cannot write its own config has a louder problem to report elsewhere.
|
|
916
|
+
*/
|
|
917
|
+
async applyRemoteGitConfig(gitDir) {
|
|
918
|
+
try {
|
|
919
|
+
await ensureRemoteGitConfig(gitDir, sharedRepoGit(gitDir, 'bare'));
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
workerLogWarn(`Could not apply remote.git config in ${gitDir}: ${getErrorMessage(err)}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Ensure the remote.git bare repo exists, seeding it from GitHub on first run.
|
|
927
|
+
*
|
|
928
|
+
* Seeded through the GitHub gateway: it fetches from GitHub, and a fresh bare repo takes its
|
|
929
|
+
* branches from the bundle it answers with (worker/github-transfer.ts), so no git command run
|
|
930
|
+
* against remote.git ever carries the credential, and no token-bearing URL is ever written to
|
|
931
|
+
* EFS.
|
|
438
932
|
*
|
|
439
|
-
* Empty-remote guard:
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
* on the already-exists fast path, since a previous run can have left a
|
|
446
|
-
* poisoned remote.git behind.
|
|
933
|
+
* Empty-remote guard: a remote.git with no base branch (one cloned from an EMPTY GitHub repo,
|
|
934
|
+
* or with a base branch never pushed) is one `fs.stat` cannot tell from a healthy one, so left
|
|
935
|
+
* unchecked it silently poisons remote.git and the stat short-circuit means it never heals. The
|
|
936
|
+
* seed refuses a GitHub without the base branch, and the base branch is verified right after
|
|
937
|
+
* seeding AND on the already-exists fast path, where a poisoned remote.git is replaced
|
|
938
|
+
* (`replacePoisonedRemoteGit`).
|
|
447
939
|
*/
|
|
448
940
|
async ensureRemoteGit() {
|
|
449
941
|
let exists;
|
|
@@ -455,51 +947,172 @@ export class CmsWorker {
|
|
|
455
947
|
exists = false;
|
|
456
948
|
}
|
|
457
949
|
if (exists) {
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
//
|
|
950
|
+
// At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
|
|
951
|
+
// scrub is git reading this config too.
|
|
952
|
+
await this.assertRemoteGitConfig();
|
|
953
|
+
// SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
|
|
954
|
+
// older worker recorded the token-bearing clone URL in its config.
|
|
463
955
|
await this.scrubPersistedRemote(this.remoteGitPath);
|
|
464
956
|
try {
|
|
465
957
|
await this.verifyBaseBranchExists(this.remoteGitPath);
|
|
466
958
|
}
|
|
467
959
|
catch (err) {
|
|
468
960
|
workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
// deletion is the operator's call.
|
|
472
|
-
throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
|
|
961
|
+
await this.replacePoisonedRemoteGit();
|
|
962
|
+
return;
|
|
473
963
|
}
|
|
964
|
+
await this.applyRemoteGitConfig(this.remoteGitPath);
|
|
474
965
|
return; // Already exists and has the base branch
|
|
475
966
|
}
|
|
476
967
|
workerLog('Initializing remote.git from GitHub...');
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
968
|
+
const stagingPath = await this.seedRemoteGitStaging();
|
|
969
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
970
|
+
workerLog('remote.git initialized');
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
|
|
974
|
+
* when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
|
|
975
|
+
* a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
|
|
976
|
+
* naming the refs at stake: unpushed work such as a settings branch.
|
|
977
|
+
*
|
|
978
|
+
* The refs are checked against the replacement itself, once it is seeded: a ref's commit counts
|
|
979
|
+
* as on GitHub only when the seed holds it, reachable from a branch GitHub had, so the decision
|
|
980
|
+
* follows exactly what would replace remote.git, whatever the gateway fetches meanwhile. They are
|
|
981
|
+
* listed again just before the swap, since the Lambda can push into remote.git while the seed
|
|
982
|
+
* runs. The swap is two renames, so the name remote.git resolves to the old repo,
|
|
983
|
+
* the new one, or for an instant nothing, never a half-built or half-deleted one.
|
|
984
|
+
*/
|
|
985
|
+
async replacePoisonedRemoteGit() {
|
|
986
|
+
const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
|
|
987
|
+
`GitHub repository was empty, or the base branch is set to one it never had)`;
|
|
988
|
+
const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
|
|
989
|
+
let refs;
|
|
990
|
+
try {
|
|
991
|
+
refs = await listRemoteGitRefs(this.remoteGitPath);
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
|
|
995
|
+
`the worker cannot tell whether replacing it would lose work. Deleting ` +
|
|
996
|
+
`${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
|
|
997
|
+
}
|
|
998
|
+
// Only a base branch the listing confirms absent: a check that failed for any other reason
|
|
999
|
+
// is no evidence the repo is poisoned.
|
|
1000
|
+
if (refs.has(`refs/heads/${this.baseBranch}`)) {
|
|
1001
|
+
throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
|
|
1002
|
+
`verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
|
|
1003
|
+
}
|
|
1004
|
+
workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
|
|
1005
|
+
const stagingPath = await this.seedRemoteGitStaging(async (staging, githubBranches) => {
|
|
1006
|
+
const missing = await unreachableFrom(this.transferContext(), staging, [...refs.values()], [...githubBranches.values()]);
|
|
1007
|
+
const unpushed = [];
|
|
1008
|
+
for (const [ref, id] of refs) {
|
|
1009
|
+
if (missing.has(id))
|
|
1010
|
+
unpushed.push(ref);
|
|
1011
|
+
}
|
|
1012
|
+
if (unpushed.length === 0)
|
|
1013
|
+
return;
|
|
1014
|
+
const shown = unpushed.slice(0, MAX_REFS_LISTED);
|
|
1015
|
+
const more = unpushed.length - shown.length;
|
|
1016
|
+
throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
|
|
1017
|
+
`GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
|
|
1018
|
+
`${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
|
|
1019
|
+
`the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
|
|
1020
|
+
}).catch((err) => {
|
|
1021
|
+
if (err instanceof RemoteGitKeptError)
|
|
1022
|
+
throw err;
|
|
1023
|
+
throw notReplaced(redactCredentials(getErrorMessage(err)));
|
|
1024
|
+
});
|
|
1025
|
+
const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
|
|
1026
|
+
let current;
|
|
1027
|
+
try {
|
|
1028
|
+
current = await listRemoteGitRefs(this.remoteGitPath);
|
|
1029
|
+
}
|
|
1030
|
+
catch (err) {
|
|
1031
|
+
await discardStaging();
|
|
1032
|
+
throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
|
|
1033
|
+
`worker checks again.`);
|
|
1034
|
+
}
|
|
1035
|
+
if (!sameRefs(refs, current)) {
|
|
1036
|
+
await discardStaging();
|
|
1037
|
+
throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
|
|
1038
|
+
}
|
|
1039
|
+
const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
|
|
1040
|
+
try {
|
|
1041
|
+
await fs.rename(this.remoteGitPath, replaced);
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
await discardStaging();
|
|
1045
|
+
throw err;
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
await fs.rename(replaced, this.remoteGitPath);
|
|
1052
|
+
await discardStaging();
|
|
1053
|
+
throw err;
|
|
1054
|
+
}
|
|
1055
|
+
// A push that resolved remote.git before the rename can still land in the old repo. One that
|
|
1056
|
+
// lands before this listing keeps it; a later one is lost from remote.git, though the clone
|
|
1057
|
+
// that pushed still has its commit.
|
|
1058
|
+
const after = await listRemoteGitRefs(replaced).catch(() => null);
|
|
1059
|
+
if (after !== null && sameRefs(refs, after)) {
|
|
1060
|
+
// remote.git is already replaced, so a push still writing here costs only this directory.
|
|
1061
|
+
await fs
|
|
1062
|
+
.rm(replaced, { recursive: true, force: true, maxRetries: 3 })
|
|
1063
|
+
.catch((err) => {
|
|
1064
|
+
workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
|
|
1069
|
+
`it may hold work GitHub does not have.`);
|
|
1070
|
+
}
|
|
1071
|
+
workerLog('remote.git replaced');
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
|
|
1075
|
+
* return its path for the caller to rename into place. `check` runs on the seeded repo, given
|
|
1076
|
+
* GitHub's branches as the seed holds them, and its throw stops the seed. Throws with the
|
|
1077
|
+
* staging directory gone.
|
|
1078
|
+
*/
|
|
1079
|
+
async seedRemoteGitStaging(check) {
|
|
1080
|
+
// Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
|
|
1081
|
+
// leaves only this staging directory, which the next boot deletes, rather than a poisoned
|
|
482
1082
|
// `remote.git` that fs.stat cannot distinguish from a healthy one.
|
|
483
1083
|
const stagingPath = `${this.remoteGitPath}.cloning`;
|
|
484
1084
|
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
485
1085
|
try {
|
|
486
|
-
await
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
await this.scrubPersistedRemote(stagingPath);
|
|
1086
|
+
const githubBranches = await seedFromGitHub(this.transferContext(), stagingPath, this.baseBranch);
|
|
1087
|
+
// The staging path is predictable, and so writable by the Lambda while seeding runs.
|
|
1088
|
+
await assertSharedRepoConfig(stagingPath, 'bare');
|
|
1089
|
+
await check?.(stagingPath, githubBranches);
|
|
491
1090
|
await this.verifyBaseBranchExists(stagingPath);
|
|
1091
|
+
await this.recordBaseBranchInRemoteHead(stagingPath);
|
|
1092
|
+
await this.applyRemoteGitConfig(stagingPath);
|
|
492
1093
|
}
|
|
493
1094
|
catch (err) {
|
|
494
|
-
|
|
1095
|
+
if (!(err instanceof RemoteGitKeptError)) {
|
|
1096
|
+
workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
|
|
1097
|
+
}
|
|
495
1098
|
// Deleting before throwing is what makes this recoverable: the next
|
|
496
1099
|
// start() sees no remote.git and re-clones, instead of sticking forever
|
|
497
1100
|
// behind a poisoned bare repo fs.stat alone cannot detect.
|
|
498
1101
|
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
1102
|
+
if (err instanceof RemoteGitKeptError)
|
|
1103
|
+
throw err;
|
|
1104
|
+
if (err instanceof SharedRepoRefusalError) {
|
|
1105
|
+
// Not the refusal's own advice: the directory it names is gone.
|
|
1106
|
+
throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
|
|
1107
|
+
`remote.git from it` +
|
|
1108
|
+
(err instanceof UntrustedRepoConfigError
|
|
1109
|
+
? ` (${err.keys.map((k) => k.key).join(', ')})`
|
|
1110
|
+
: '') +
|
|
1111
|
+
`, so it was removed. Find out what wrote there; restarting the worker seeds it again.`);
|
|
1112
|
+
}
|
|
499
1113
|
throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
|
|
500
1114
|
}
|
|
501
|
-
|
|
502
|
-
workerLog('remote.git initialized');
|
|
1115
|
+
return stagingPath;
|
|
503
1116
|
}
|
|
504
1117
|
// --- Task-queue cluster (worker/task-runner.ts) ------------------------
|
|
505
1118
|
//
|
|
@@ -524,117 +1137,61 @@ export class CmsWorker {
|
|
|
524
1137
|
async updateBranchMetadata(task, result) {
|
|
525
1138
|
return updateBranchMetadata(this.ctx(), task, result);
|
|
526
1139
|
}
|
|
527
|
-
async pushBranchToGitHub(branch) {
|
|
528
|
-
return pushBranchToGitHub(this.ctx(), branch);
|
|
1140
|
+
async pushBranchToGitHub(branch, signal) {
|
|
1141
|
+
return pushBranchToGitHub(this.ctx(), branch, signal);
|
|
529
1142
|
}
|
|
530
1143
|
/**
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* `resolveWorkerGitHubAuth` throws for a half-configured credential (both
|
|
537
|
-
* set, neither set, an unusable mint timeout or refresh interval), and a
|
|
538
|
-
* throw during `new CmsWorker(...)` lands BEFORE the only code that writes
|
|
539
|
-
* `lastFatalError` — start()'s catch.
|
|
540
|
-
*
|
|
541
|
-
* Idempotent, and it does NOT replace an `octokit` a test has already
|
|
542
|
-
* assigned onto the instance — see the field's comment.
|
|
1144
|
+
* The worker's GitHub gateway, created on first use: inside start()'s try, or wherever a test
|
|
1145
|
+
* entry point first reaches it. Creating it resolves the credential, which throws for a
|
|
1146
|
+
* half-configured one (both set, neither set, an unusable mint timeout or refresh interval) --
|
|
1147
|
+
* never in the constructor, for the reason `ensureSettingsBranch()` gives. Keeps a gateway a
|
|
1148
|
+
* test installed.
|
|
543
1149
|
*/
|
|
544
|
-
|
|
545
|
-
if (
|
|
546
|
-
|
|
1150
|
+
github() {
|
|
1151
|
+
if (this.gateway !== undefined)
|
|
1152
|
+
return this.gateway;
|
|
1153
|
+
const { githubGateway } = this.config;
|
|
1154
|
+
if (githubGateway === undefined) {
|
|
1155
|
+
this.gateway = createLocalGitHubGateway(this.localGitHubGatewayOptions());
|
|
1156
|
+
return this.gateway;
|
|
547
1157
|
}
|
|
548
|
-
|
|
549
|
-
|
|
1158
|
+
const conflicting = [];
|
|
1159
|
+
if (this.config.githubToken)
|
|
1160
|
+
conflicting.push('githubToken');
|
|
1161
|
+
if (this.config.githubAppAuth !== undefined)
|
|
1162
|
+
conflicting.push('githubAppAuth');
|
|
1163
|
+
if (this.config.refreshGitHubToken !== undefined)
|
|
1164
|
+
conflicting.push('refreshGitHubToken');
|
|
1165
|
+
if ((this.config.protectedBranches?.length ?? 0) > 0)
|
|
1166
|
+
conflicting.push('protectedBranches');
|
|
1167
|
+
if (conflicting.length > 0) {
|
|
1168
|
+
throw new Error(`CmsWorker: githubGateway is exclusive with ${conflicting.join(', ')}, which configure the ` +
|
|
1169
|
+
'in-process gateway; the gateway it is given holds the credential and the protected set');
|
|
550
1170
|
}
|
|
551
|
-
|
|
1171
|
+
this.gateway = githubGateway;
|
|
1172
|
+
return this.gateway;
|
|
552
1173
|
}
|
|
553
|
-
/**
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
octokitClient() {
|
|
561
|
-
this.ensureGitHubAuth();
|
|
562
|
-
return this.octokit;
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* The single seam through which every git-over-HTTPS credential reaches a git
|
|
566
|
-
* command. The only other consumer is Octokit, built from the same resolution
|
|
567
|
-
* by `ensureGitHubAuth()` above.
|
|
568
|
-
*
|
|
569
|
-
* Async because under GitHub App auth `resolveGitToken` mints an installation
|
|
570
|
-
* token lasting about an hour. NOTHING may cache what this returns — a URL
|
|
571
|
-
* built from an installation token goes stale with it — and resolving per use
|
|
572
|
-
* is cheap, since `@octokit/auth-app` answers from its own cache until the
|
|
573
|
-
* token is near expiry.
|
|
574
|
-
*
|
|
575
|
-
* A mint failure propagates AS THROWN, carrying the `.status` that
|
|
576
|
-
* `isPermanentTaskFailure` classifies on — see github-auth.ts.
|
|
577
|
-
*
|
|
578
|
-
* Do NOT add a parallel token accessor alongside it. Every instance-backed
|
|
579
|
-
* WorkerContext member stays a function precisely so tests can replace it
|
|
580
|
-
* through the instance (worker-context.ts's INVARIANT); a second credential
|
|
581
|
-
* path would be one nothing stubs.
|
|
582
|
-
*/
|
|
583
|
-
async buildGitHubUrl() {
|
|
584
|
-
const token = await this.ensureGitHubAuth().resolveGitToken();
|
|
585
|
-
return `https://x-access-token:${token}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
|
|
1174
|
+
/** What worker/github-transfer.ts needs, for the seeds that run before the loops' context. */
|
|
1175
|
+
transferContext() {
|
|
1176
|
+
return {
|
|
1177
|
+
github: () => this.github(),
|
|
1178
|
+
remoteGitPath: this.remoteGitPath,
|
|
1179
|
+
taskTimeoutMs: this.taskTimeoutMs,
|
|
1180
|
+
};
|
|
586
1181
|
}
|
|
587
|
-
/**
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
* degrade alike: on the token path a GitHub 502 during boot is absorbed (a
|
|
601
|
-
* warm `remote.git` short-circuits `ensureRemoteGit`, `Promise.allSettled`
|
|
602
|
-
* swallows the initial `syncGit`) so the worker starts and its loops retry,
|
|
603
|
-
* while rethrowing every error class would make the App path exit and systemd
|
|
604
|
-
* crash-loop it until GitHub recovered — each iteration telling the operator
|
|
605
|
-
* to check their private key.
|
|
606
|
-
*
|
|
607
|
-
* But fail CLOSED, via `isTransientAuthFailure` rather than the inverse of
|
|
608
|
-
* `isPermanentTaskFailure`: that classifier defaults a status-less error to
|
|
609
|
-
* transient, which is right on the task path (bounded by `maxRetries`) and
|
|
610
|
-
* wrong here (bounded by nothing). A key that never reaches GitHub at all —
|
|
611
|
-
* the wrong type, or too mangled to sign with — fails locally and
|
|
612
|
-
* status-lessly, so defaulting to transient would boot a worker with a dead
|
|
613
|
-
* credential, record no `lastFatalError`, and show healthy in the admin panel
|
|
614
|
-
* while every task and every sync failed.
|
|
615
|
-
*/
|
|
616
|
-
async preflightGitHubAppAuth() {
|
|
617
|
-
if (!this.config.githubAppAuth)
|
|
618
|
-
return;
|
|
619
|
-
try {
|
|
620
|
-
await this.ensureGitHubAuth().resolveGitToken();
|
|
621
|
-
}
|
|
622
|
-
catch (err) {
|
|
623
|
-
// [REDACT] Both messages below reach worker-status.json and the browser.
|
|
624
|
-
const detail = redactCredentials(getErrorMessage(err));
|
|
625
|
-
if (isTransientAuthFailure(err)) {
|
|
626
|
-
workerLogWarn(`Could not verify GitHub App authentication at startup: ${detail}. ` +
|
|
627
|
-
'Continuing — this looks transient, and the credential is minted again on first use.');
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
// Re-thrown WITH context, unlike buildGitHubUrl() above, which must
|
|
631
|
-
// preserve the error identity for task classification. Nothing classifies
|
|
632
|
-
// a startup failure -- start()'s catch records the message and the
|
|
633
|
-
// process exits -- so the operator-facing wording wins here.
|
|
634
|
-
throw new Error(`GitHub App authentication failed: ${detail}. ` +
|
|
635
|
-
'Check the app id, the installation id, and that the private key belongs to that app.');
|
|
636
|
-
}
|
|
637
|
-
workerLog('GitHub App authentication verified');
|
|
1182
|
+
/** What the in-process gateway is built from; test-utils/worker-gateway.ts builds from it too. */
|
|
1183
|
+
localGitHubGatewayOptions() {
|
|
1184
|
+
return {
|
|
1185
|
+
githubOwner: this.config.githubOwner,
|
|
1186
|
+
githubRepo: this.config.githubRepo,
|
|
1187
|
+
auth: resolveWorkerGitHubAuth(this.config),
|
|
1188
|
+
githubApp: Boolean(this.config.githubAppAuth),
|
|
1189
|
+
stateDirectory: this.stateDirectory,
|
|
1190
|
+
workspacePath: this.config.workspacePath,
|
|
1191
|
+
timeoutMs: this.taskTimeoutMs,
|
|
1192
|
+
baseBranch: this.config.baseBranch,
|
|
1193
|
+
protectedBranches: this.config.protectedBranches,
|
|
1194
|
+
};
|
|
638
1195
|
}
|
|
639
1196
|
/**
|
|
640
1197
|
* The workspace directory for a branch named by its GIT REF name -- the form
|
|
@@ -699,78 +1256,8 @@ export class CmsWorker {
|
|
|
699
1256
|
async syncGit() {
|
|
700
1257
|
return syncGit(this.ctx());
|
|
701
1258
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
*
|
|
705
|
-
* One of `refreshGitHubCredential`'s two call sites, and the one that works
|
|
706
|
-
* when nobody is publishing: it fetches from GitHub every `gitSyncInterval`
|
|
707
|
-
* whether or not anyone is editing, so a credential that has stopped working
|
|
708
|
-
* surfaces here even with no push queued for days.
|
|
709
|
-
*
|
|
710
|
-
* The SYNC failure is what propagates to `scheduleLoop`'s catch;
|
|
711
|
-
* `refreshGitHubCredential` never throws, so nothing it does can replace it.
|
|
712
|
-
*/
|
|
713
|
-
async syncGitWithCredentialRefresh() {
|
|
714
|
-
try {
|
|
715
|
-
await this.syncGit();
|
|
716
|
-
}
|
|
717
|
-
catch (err) {
|
|
718
|
-
await this.refreshGitHubCredential();
|
|
719
|
-
throw err;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Re-read the GitHub credential, because an operation using it just failed.
|
|
724
|
-
*
|
|
725
|
-
* **Two call sites, each covering what the other cannot.** The git-sync loop
|
|
726
|
-
* (`syncGitWithCredentialRefresh`) notices a dead credential when nobody is
|
|
727
|
-
* publishing. `processTaskQueue`'s per-task catch is what saves a publish: a
|
|
728
|
-
* push task spends its retry budget on a 5s/10s/20s backoff, well inside one
|
|
729
|
-
* 5-minute sync interval, so with the sync loop as the only trigger a publish
|
|
730
|
-
* meeting a rotated token fails permanently while the working one is already
|
|
731
|
-
* in the secret store. Every consumer reaches the credential through
|
|
732
|
-
* `ensureGitHubAuth()`, which reads it per use, so a refresh from either site
|
|
733
|
-
* repairs all of them for their NEXT use — but not an attempt already failed.
|
|
734
|
-
*
|
|
735
|
-
* NOT gated on the error looking auth-shaped, at either site: a `git
|
|
736
|
-
* fetch`/`push` rejected for a dead token throws a plain simple-git error
|
|
737
|
-
* (exit 128, no HTTP `.status`) that `isPermanentTaskFailure` reads as
|
|
738
|
-
* transient, so a gate keyed on it would never fire. Two floors bound the
|
|
739
|
-
* cost instead — core's `refreshGitHubTokenMinIntervalMs` (default 60s,
|
|
740
|
-
* enforced by `refreshCredential` in github-auth.ts) and whatever floor the
|
|
741
|
-
* provider keeps (the AWS one reads at most once per five minutes) — and both
|
|
742
|
-
* call sites share both, so a read issued by one throttles the other. On the
|
|
743
|
-
* GitHub App path the refresh is a no-op.
|
|
744
|
-
*
|
|
745
|
-
* **Never throws**: both callers are already reporting the failure that must
|
|
746
|
-
* reach the log. **Bounded by `taskTimeoutMs`**, because the task loop awaits
|
|
747
|
-
* it and a read that never settled would stop every publish queued behind it
|
|
748
|
-
* (an adopter's provider may have no bound at all; the AWS one can take 87s
|
|
749
|
-
* for one `getSecret`). A losing read is not cancelled and may land later, but
|
|
750
|
-
* `refreshCredential` discards a result older than one already applied.
|
|
751
|
-
*/
|
|
752
|
-
async refreshGitHubCredential() {
|
|
753
|
-
let timer;
|
|
754
|
-
const timedOut = new Promise((_, reject) => {
|
|
755
|
-
timer = setTimeout(() => reject(new Error(`the re-read did not settle within ${this.taskTimeoutMs}ms`)), this.taskTimeoutMs);
|
|
756
|
-
});
|
|
757
|
-
try {
|
|
758
|
-
// `ensureGitHubAuth()` inside the try: it throws for a half-configured
|
|
759
|
-
// credential, and that has to be logged like any other refresh failure.
|
|
760
|
-
await Promise.race([this.ensureGitHubAuth().refreshCredential(), timedOut]);
|
|
761
|
-
}
|
|
762
|
-
catch (err) {
|
|
763
|
-
// [REDACT] The message can name the secret and, on a malformed-secret
|
|
764
|
-
// path, quote what was read. Console only, but the rule is uniform --
|
|
765
|
-
// see redactCredentials in utils/error.ts.
|
|
766
|
-
workerLogError('Failed to re-read the GitHub credential after a failure:', redactCredentials(getErrorMessage(err)));
|
|
767
|
-
}
|
|
768
|
-
finally {
|
|
769
|
-
clearTimeout(timer);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
async pushSettingsBranches(git, trackedNames) {
|
|
773
|
-
return pushSettingsBranches(this.ctx(), git, trackedNames);
|
|
1259
|
+
async pushSettingsBranches(git, trackedTips) {
|
|
1260
|
+
return pushSettingsBranches(this.ctx(), git, trackedTips);
|
|
774
1261
|
}
|
|
775
1262
|
async refreshBaseBranchWorkspace() {
|
|
776
1263
|
return refreshBaseBranchWorkspace(this.ctx());
|