@taskforcehq/taskforce 0.3.326 → 0.3.327
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/LICENSE.md +68 -0
- package/README.md +19 -0
- package/dist/Taskforce.module.css +2173 -561
- package/dist/TaskforceCore.d.ts +1 -0
- package/dist/TaskforceCore.js +65 -22
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.d.ts +1 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.js +3 -2
- package/dist/annotatedAttachments/commandService.d.ts +68 -0
- package/dist/annotatedAttachments/commandService.js +348 -0
- package/dist/annotatedAttachments/service.d.ts +2 -0
- package/dist/annotatedAttachments/service.js +13 -12
- package/dist/cli.js +158 -9
- package/dist/cliOpenPort.d.ts +1 -0
- package/dist/cliOpenPort.js +4 -0
- package/dist/compat/taskforceAgentDefinitionCompat.d.ts +12 -0
- package/dist/compat/taskforceAgentDefinitionCompat.js +25 -0
- package/dist/compat/workspaceSyncCompat.js +6 -2
- package/dist/compat/workspaceSyncV3CombinedCoverageCompat.d.ts +6 -0
- package/dist/compat/workspaceSyncV3CombinedCoverageCompat.js +22 -0
- package/dist/compat/workspaceSyncV3OperationCompat.d.ts +7 -0
- package/dist/compat/workspaceSyncV3OperationCompat.js +30 -0
- package/dist/components/activity/EntityActivityTimeline.d.ts +10 -2
- package/dist/components/activity/EntityActivityTimeline.js +73 -35
- package/dist/components/context/AttachmentPreviewHost.d.ts +6 -0
- package/dist/components/context/AttachmentPreviewHost.js +222 -0
- package/dist/components/context/ContextAttachmentManager.d.ts +4 -0
- package/dist/components/context/ContextAttachmentManager.js +418 -208
- package/dist/components/features/AiProfilesModule.d.ts +24 -0
- package/dist/components/features/AiProfilesModule.js +744 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +4 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +351 -79
- package/dist/components/features/DocumentGenerateModal.js +23 -5
- package/dist/components/features/DocumentIndex.d.ts +19 -1
- package/dist/components/features/DocumentIndex.js +104 -15
- package/dist/components/features/DocumentViewer.d.ts +9 -1
- package/dist/components/features/DocumentViewer.js +721 -100
- package/dist/components/features/DocumentWorkspace.d.ts +37 -2
- package/dist/components/features/DocumentWorkspace.js +21 -6
- package/dist/components/features/TaskSettings.d.ts +6 -27
- package/dist/components/features/TaskSettings.js +232 -143
- package/dist/components/features/TaskforceAgentsModule.d.ts +36 -0
- package/dist/components/features/TaskforceAgentsModule.js +1782 -0
- package/dist/components/features/agentBehavior/AgentBehaviorManagerShared.d.ts +94 -0
- package/dist/components/features/agentBehavior/AgentBehaviorManagerShared.js +70 -0
- package/dist/components/features/agentCapabilities/AgentCapabilitiesPrototype.d.ts +65 -0
- package/dist/components/features/agentCapabilities/AgentCapabilitiesPrototype.js +115 -0
- package/dist/components/features/agentRoles/RoleManagerPanel.d.ts +4 -0
- package/dist/components/features/agentRoles/RoleManagerPanel.js +382 -0
- package/dist/components/features/agentSkills/SkillManagerPanel.d.ts +5 -0
- package/dist/components/features/agentSkills/SkillManagerPanel.js +355 -0
- package/dist/components/features/documentWorkspaceModel.d.ts +16 -4
- package/dist/components/features/documentWorkspaceModel.js +30 -5
- package/dist/components/features/planning/PlanningArchiveConfirmation.d.ts +9 -0
- package/dist/components/features/planning/PlanningArchiveConfirmation.js +19 -0
- package/dist/components/features/planning/PlanningCardIdentity.d.ts +12 -0
- package/dist/components/features/planning/PlanningCardIdentity.js +44 -0
- package/dist/components/features/planning/PlanningEntityDetails.d.ts +36 -0
- package/dist/components/features/planning/PlanningEntityDetails.js +131 -0
- package/dist/components/features/planning/PlanningEntitySummary.d.ts +44 -0
- package/dist/components/features/planning/PlanningEntitySummary.js +97 -0
- package/dist/components/features/planning/PlanningLinkPicker.d.ts +9 -0
- package/dist/components/features/planning/PlanningLinkPicker.js +26 -0
- package/dist/components/features/planning/PlanningModule.d.ts +6 -0
- package/dist/components/features/planning/PlanningModule.js +571 -0
- package/dist/components/features/planning/PlanningOwnerDropdown.d.ts +27 -0
- package/dist/components/features/planning/PlanningOwnerDropdown.js +80 -0
- package/dist/components/features/planning/PlanningTaskArrangeControl.d.ts +5 -0
- package/dist/components/features/planning/PlanningTaskArrangeControl.js +11 -0
- package/dist/components/features/planning/PlanningTaskCard.d.ts +20 -0
- package/dist/components/features/planning/PlanningTaskCard.js +26 -0
- package/dist/components/features/planning/PlanningTaskOrderList.d.ts +10 -0
- package/dist/components/features/planning/PlanningTaskOrderList.js +52 -0
- package/dist/components/features/planning/PlanningUnlinkConfirmation.d.ts +15 -0
- package/dist/components/features/planning/PlanningUnlinkConfirmation.js +7 -0
- package/dist/components/features/planning/planningCreateIntent.d.ts +10 -0
- package/dist/components/features/planning/planningCreateIntent.js +16 -0
- package/dist/components/features/planning/planningNavigator.d.ts +33 -0
- package/dist/components/features/planning/planningNavigator.js +96 -0
- package/dist/components/features/planning/planningTaskArrangement.d.ts +18 -0
- package/dist/components/features/planning/planningTaskArrangement.js +106 -0
- package/dist/components/features/planning/planningWorkspaceModel.d.ts +121 -0
- package/dist/components/features/planning/planningWorkspaceModel.js +5 -0
- package/dist/components/features/settings/CategoryManager.js +2 -2
- package/dist/components/features/settings/TaxonomyEditor.js +1 -1
- package/dist/components/features/settings/TaxonomyManager.js +1 -1
- package/dist/components/features/settings/TypeManager.js +1 -1
- package/dist/components/features/taskforceAgentAssignments/AgentBehaviorAssignments.d.ts +10 -0
- package/dist/components/features/taskforceAgentAssignments/AgentBehaviorAssignments.js +175 -0
- package/dist/components/features/taskforceAgentAssignments/agentBehaviorCatalogApi.d.ts +27 -0
- package/dist/components/features/taskforceAgentAssignments/agentBehaviorCatalogApi.js +24 -0
- package/dist/components/features/taskforceAgentEditorModel.d.ts +109 -0
- package/dist/components/features/taskforceAgentEditorModel.js +145 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +20 -1
- package/dist/components/features/useDocumentWorkspaceController.js +106 -21
- package/dist/components/features/workflowManager/WorkflowManagerModule.d.ts +11 -0
- package/dist/components/features/workflowManager/WorkflowManagerModule.js +313 -0
- package/dist/components/features/workflowManager/WorkflowPublishDialog.d.ts +11 -0
- package/dist/components/features/workflowManager/WorkflowPublishDialog.js +8 -0
- package/dist/components/features/workflowManager/types.d.ts +74 -0
- package/dist/components/features/workflowManager/types.js +1 -0
- package/dist/components/features/workflowManager/useWorkflowManager.d.ts +30 -0
- package/dist/components/features/workflowManager/useWorkflowManager.js +213 -0
- package/dist/components/features/workflowManager/workflowManagerApi.d.ts +52 -0
- package/dist/components/features/workflowManager/workflowManagerApi.js +142 -0
- package/dist/components/task/TaskActionHeader.d.ts +6 -3
- package/dist/components/task/TaskActionHeader.js +46 -53
- package/dist/components/task/TaskCard.d.ts +10 -2
- package/dist/components/task/TaskCard.js +103 -54
- package/dist/components/task/TaskContextUpload.d.ts +1 -0
- package/dist/components/task/TaskContextUpload.js +3 -2
- package/dist/components/task/TaskForm.d.ts +12 -17
- package/dist/components/task/TaskForm.js +544 -199
- package/dist/components/task/TaskImageReviews.d.ts +8 -0
- package/dist/components/task/TaskImageReviews.js +47 -0
- package/dist/components/task/TaskKanban.d.ts +9 -10
- package/dist/components/task/TaskKanban.js +70 -18
- package/dist/components/task/TaskList.d.ts +9 -8
- package/dist/components/task/TaskList.js +46 -32
- package/dist/components/task/TaskReferenceBadge.d.ts +2 -1
- package/dist/components/task/TaskReferenceBadge.js +3 -2
- package/dist/components/task/TaskStatusActions.d.ts +3 -1
- package/dist/components/task/TaskStatusActions.js +17 -6
- package/dist/components/task/TaskWorkflowAssignmentField.d.ts +18 -0
- package/dist/components/task/TaskWorkflowAssignmentField.js +362 -0
- package/dist/components/task/TaskWorkstreamPicker.d.ts +8 -0
- package/dist/components/task/TaskWorkstreamPicker.js +24 -0
- package/dist/components/task/TrashRestoreControls.d.ts +18 -0
- package/dist/components/task/TrashRestoreControls.js +71 -0
- package/dist/components/ui/AiIdentityRosterCard.d.ts +20 -0
- package/dist/components/ui/AiIdentityRosterCard.js +5 -0
- package/dist/components/ui/AssetTraySortControl.d.ts +10 -0
- package/dist/components/ui/AssetTraySortControl.js +38 -0
- package/dist/components/ui/AssigneeIdentity.d.ts +12 -0
- package/dist/components/ui/AssigneeIdentity.js +41 -0
- package/dist/components/ui/DiscardChangesDialog.d.ts +10 -0
- package/dist/components/ui/DiscardChangesDialog.js +7 -0
- package/dist/components/ui/DisclosureSectionHeader.d.ts +4 -1
- package/dist/components/ui/DisclosureSectionHeader.js +2 -2
- package/dist/components/ui/EditableAvatarButton.d.ts +6 -1
- package/dist/components/ui/EditableAvatarButton.js +6 -5
- package/dist/components/ui/HierarchyActionBadge.d.ts +2 -1
- package/dist/components/ui/HierarchyActionBadge.js +2 -2
- package/dist/components/ui/Markdown.d.ts +13 -3
- package/dist/components/ui/Markdown.fixture.d.ts +1 -0
- package/dist/components/ui/Markdown.fixture.js +23 -0
- package/dist/components/ui/Markdown.js +158 -68
- package/dist/components/ui/Modal.d.ts +3 -1
- package/dist/components/ui/Modal.js +85 -19
- package/dist/components/ui/MultiSelectFilter.d.ts +9 -2
- package/dist/components/ui/MultiSelectFilter.js +26 -5
- package/dist/components/ui/ReferenceBadgeButton.js +2 -3
- package/dist/components/ui/TaxonomyDropdown.d.ts +10 -1
- package/dist/components/ui/TaxonomyDropdown.js +149 -58
- package/dist/components/ui/TopNoticeLayer.d.ts +2 -1
- package/dist/components/ui/TopNoticeLayer.js +5 -2
- package/dist/components/ui/useFallbackImageUrl.d.ts +4 -0
- package/dist/components/ui/useFallbackImageUrl.js +20 -0
- package/dist/components/views/AppShellHeader.js +2 -2
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +1662 -598
- package/dist/components/views/WidgetView.d.ts +1 -0
- package/dist/components/views/WidgetView.js +95 -30
- package/dist/components/views/chrome/WorkspaceChromeSections.d.ts +4 -2
- package/dist/components/views/chrome/WorkspaceChromeSections.js +3 -6
- package/dist/components/views/chrome/workspaceChrome.d.ts +5 -8
- package/dist/components/views/chrome/workspaceChrome.js +62 -27
- package/dist/components/views/panels/FilterToolbar.d.ts +4 -3
- package/dist/components/views/panels/FilterToolbar.js +8 -5
- package/dist/components/views/panels/FilterToolbarControls.d.ts +11 -2
- package/dist/components/views/panels/FilterToolbarControls.js +35 -20
- package/dist/components/views/panels/PlanningDrawer.d.ts +60 -49
- package/dist/components/views/panels/PlanningDrawer.js +978 -313
- package/dist/components/views/planningScope.js +7 -1
- package/dist/components/views/standalone/modals/AccountSettingsModal.d.ts +62 -0
- package/dist/components/views/standalone/modals/AccountSettingsModal.js +197 -0
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +7 -2
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +56 -9
- package/dist/components/views/standalone/modals/HelpModal.d.ts +1 -2
- package/dist/components/views/standalone/modals/HelpModal.js +3 -4
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +8 -9
- package/dist/components/views/standalone/modals/SyncStatusModal.js +85 -25
- package/dist/components/views/standalone/modals/TeamManagementModal.js +1 -1
- package/dist/config/applicationBaseUrl.d.ts +2 -0
- package/dist/config/applicationBaseUrl.js +23 -0
- package/dist/config/envSchema.d.ts +1 -1
- package/dist/config/envSchema.js +166 -5
- package/dist/core/AdminAuditRetentionService.d.ts +43 -0
- package/dist/core/AdminAuditRetentionService.js +126 -0
- package/dist/core/AiProfileService.d.ts +34 -0
- package/dist/core/AiProfileService.js +510 -251
- package/dist/core/AttachmentLinkService.d.ts +10 -2
- package/dist/core/AttachmentLinkService.js +44 -12
- package/dist/core/AuthIdentityService.d.ts +6 -0
- package/dist/core/AuthIdentityService.js +38 -0
- package/dist/core/AuthTokenService.d.ts +1 -0
- package/dist/core/AuthTokenService.js +13 -0
- package/dist/core/DeletedTaskLifecycleService.d.ts +4 -2
- package/dist/core/DeletedTaskLifecycleService.js +124 -38
- package/dist/core/GlobalSettingsService.d.ts +3 -1
- package/dist/core/GlobalSettingsService.js +30 -4
- package/dist/core/InitiativeDurableMutationService.d.ts +89 -0
- package/dist/core/InitiativeDurableMutationService.js +187 -0
- package/dist/core/McpTokenService.d.ts +1 -0
- package/dist/core/McpTokenService.js +39 -13
- package/dist/core/PlanningCommandPreparationService.d.ts +60 -0
- package/dist/core/PlanningCommandPreparationService.js +149 -0
- package/dist/core/ScheduleCommandService.d.ts +76 -0
- package/dist/core/ScheduleCommandService.js +165 -0
- package/dist/core/SyncReconciliationService.d.ts +29 -6
- package/dist/core/SyncReconciliationService.js +256 -84
- package/dist/core/TaskActivityService.d.ts +22 -2
- package/dist/core/TaskActivityService.js +177 -29
- package/dist/core/TaskAttachmentLinksDurableMutationService.d.ts +31 -0
- package/dist/core/TaskAttachmentLinksDurableMutationService.js +231 -0
- package/dist/core/TaskChecklistCommandService.d.ts +50 -0
- package/dist/core/TaskChecklistCommandService.js +111 -0
- package/dist/core/TaskDurableCreateMutationService.d.ts +38 -0
- package/dist/core/TaskDurableCreateMutationService.js +85 -0
- package/dist/core/TaskDurableDeleteMutationService.d.ts +47 -0
- package/dist/core/TaskDurableDeleteMutationService.js +54 -0
- package/dist/core/TaskDurableLifecycleMutationService.d.ts +38 -0
- package/dist/core/TaskDurableLifecycleMutationService.js +42 -0
- package/dist/core/TaskDurableRelationshipMutationService.d.ts +58 -0
- package/dist/core/TaskDurableRelationshipMutationService.js +66 -0
- package/dist/core/TaskDurableRestoreMutationService.d.ts +31 -0
- package/dist/core/TaskDurableRestoreMutationService.js +33 -0
- package/dist/core/TaskDurableStatusMutationService.d.ts +74 -0
- package/dist/core/TaskDurableStatusMutationService.js +247 -0
- package/dist/core/TaskDurableSyncMutationService.d.ts +34 -0
- package/dist/core/TaskDurableSyncMutationService.js +70 -0
- package/dist/core/TaskDurableWorkflowMutationService.d.ts +48 -0
- package/dist/core/TaskDurableWorkflowMutationService.js +59 -0
- package/dist/core/TaskLifecycleCommandService.d.ts +101 -0
- package/dist/core/TaskLifecycleCommandService.js +161 -0
- package/dist/core/TaskRootCommandPreparationService.d.ts +35 -0
- package/dist/core/TaskRootCommandPreparationService.js +103 -0
- package/dist/core/Taskforce.d.ts +479 -59
- package/dist/core/Taskforce.js +3671 -841
- package/dist/core/WorkflowAssignmentService.d.ts +40 -0
- package/dist/core/WorkflowAssignmentService.js +158 -0
- package/dist/core/WorkflowExecutionService.d.ts +69 -0
- package/dist/core/WorkflowExecutionService.js +342 -0
- package/dist/core/WorkflowTaskActivationService.d.ts +32 -0
- package/dist/core/WorkflowTaskActivationService.js +63 -0
- package/dist/core/WorkflowTemplateService.d.ts +64 -0
- package/dist/core/WorkflowTemplateService.js +223 -0
- package/dist/core/WorkspaceLifecycleService.d.ts +1 -0
- package/dist/core/WorkspaceLifecycleService.js +18 -0
- package/dist/core/WorkstreamDurableMutationService.d.ts +90 -0
- package/dist/core/WorkstreamDurableMutationService.js +184 -0
- package/dist/core/planningReferencePolicy.d.ts +14 -0
- package/dist/core/planningReferencePolicy.js +53 -0
- package/dist/core/taskReferencePolicy.js +24 -3
- package/dist/core/types.d.ts +10 -10
- package/dist/documentReviews/DocumentReviewCommentStore.d.ts +22 -2
- package/dist/documentReviews/DocumentReviewCommentStore.js +92 -8
- package/dist/documentReviews/commandService.d.ts +51 -0
- package/dist/documentReviews/commandService.js +63 -0
- package/dist/documentReviews/service.d.ts +20 -1
- package/dist/documentReviews/service.js +234 -24
- package/dist/documentReviews/types.d.ts +18 -1
- package/dist/hooks/sync/bootstrap.d.ts +3 -7
- package/dist/hooks/sync/bootstrap.js +6 -14
- package/dist/hooks/sync/browserSyncAuthority.d.ts +21 -0
- package/dist/hooks/sync/browserSyncAuthority.js +70 -0
- package/dist/hooks/sync/browserSyncRemoteServices.d.ts +29 -0
- package/dist/hooks/sync/browserSyncRemoteServices.js +58 -0
- package/dist/hooks/sync/controlPlane.d.ts +1 -37
- package/dist/hooks/sync/controlPlane.js +1 -78
- package/dist/hooks/sync/lifecyclePolicy.d.ts +1 -45
- package/dist/hooks/sync/lifecyclePolicy.js +1 -93
- package/dist/hooks/sync/orchestratorShared.d.ts +9 -3
- package/dist/hooks/sync/orchestratorShared.js +70 -96
- package/dist/hooks/sync/pullLifecycle.d.ts +1 -31
- package/dist/hooks/sync/pullLifecycle.js +1 -24
- package/dist/hooks/sync/realtime.d.ts +4 -1
- package/dist/hooks/sync/realtime.js +103 -3
- package/dist/hooks/sync/recovery.d.ts +1 -84
- package/dist/hooks/sync/recovery.js +1 -245
- package/dist/hooks/sync/transfers.d.ts +8 -129
- package/dist/hooks/sync/transfers.js +19 -581
- package/dist/hooks/sync/useDataVersionRefresh.d.ts +6 -1
- package/dist/hooks/sync/useDataVersionRefresh.js +127 -12
- package/dist/hooks/sync/useDurableLocalOutboxDrain.d.ts +14 -0
- package/dist/hooks/sync/useDurableLocalOutboxDrain.js +208 -0
- package/dist/hooks/sync/useDurableTaskCreateMutation.d.ts +15 -0
- package/dist/hooks/sync/useDurableTaskCreateMutation.js +152 -0
- package/dist/hooks/sync/useDurableTaskDeleteMutation.d.ts +11 -0
- package/dist/hooks/sync/useDurableTaskDeleteMutation.js +21 -0
- package/dist/hooks/sync/useDurableTaskLifecycleMutation.d.ts +12 -0
- package/dist/hooks/sync/useDurableTaskLifecycleMutation.js +22 -0
- package/dist/hooks/sync/useDurableTaskMetadataMutation.d.ts +14 -0
- package/dist/hooks/sync/useDurableTaskMetadataMutation.js +24 -0
- package/dist/hooks/sync/useDurableTaskRestoreMutation.d.ts +12 -0
- package/dist/hooks/sync/useDurableTaskRestoreMutation.js +24 -0
- package/dist/hooks/sync/useDurableTaskStatusMutation.d.ts +18 -0
- package/dist/hooks/sync/useDurableTaskStatusMutation.js +23 -0
- package/dist/hooks/sync/useLocalSyncCoordinatorSnapshot.d.ts +23 -0
- package/dist/hooks/sync/useLocalSyncCoordinatorSnapshot.js +414 -0
- package/dist/hooks/sync/useSyncStatusActions.d.ts +2 -1
- package/dist/hooks/sync/useSyncStatusActions.js +8 -3
- package/dist/hooks/tasks/taskHttpMutation.d.ts +25 -0
- package/dist/hooks/tasks/taskHttpMutation.js +134 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.d.ts +6 -1
- package/dist/hooks/tasks/useCurrentTaskRelations.js +32 -6
- package/dist/hooks/tasks/useTaskEditorNavigation.d.ts +4 -3
- package/dist/hooks/tasks/useTaskEditorNavigation.js +9 -13
- package/dist/hooks/tasks/useTaskFormActions.d.ts +17 -2
- package/dist/hooks/tasks/useTaskFormActions.js +309 -55
- package/dist/hooks/tasks/useTaskReferenceContract.d.ts +5 -0
- package/dist/hooks/tasks/useTaskReferenceContract.js +17 -0
- package/dist/hooks/tasks/useTaskRelationships.d.ts +1 -3
- package/dist/hooks/tasks/useTaskRelationships.js +5 -25
- package/dist/hooks/tasks/useTrashSelection.d.ts +12 -0
- package/dist/hooks/tasks/useTrashSelection.js +49 -0
- package/dist/hooks/ui/useInitiativeTemplates.d.ts +24 -0
- package/dist/hooks/ui/useInitiativeTemplates.js +22 -0
- package/dist/hooks/ui/useSettingsModel.d.ts +5 -29
- package/dist/hooks/ui/useSettingsModel.js +31 -15
- package/dist/hooks/ui/useSettingsPersistence.d.ts +0 -1
- package/dist/hooks/ui/useSettingsPersistence.js +4 -8
- package/dist/hooks/ui/useSettingsTaxonomyManagement.js +31 -0
- package/dist/hooks/useFilterSort.d.ts +2 -2
- package/dist/hooks/useFilterSort.js +19 -15
- package/dist/hooks/useRealtimeSync.d.ts +10 -0
- package/dist/hooks/useRealtimeSync.js +57 -4
- package/dist/hooks/useSyncOrchestrator.d.ts +32 -1
- package/dist/hooks/useSyncOrchestrator.js +963 -287
- package/dist/hooks/useTaskData.d.ts +11 -2
- package/dist/hooks/useTaskData.js +59 -26
- package/dist/hooks/useTaskMutations.d.ts +42 -5
- package/dist/hooks/useTaskMutations.js +750 -125
- package/dist/hooks/useTaskforce.d.ts +114 -118
- package/dist/hooks/useTaskforce.js +1102 -449
- package/dist/hooks/useUiNotice.d.ts +5 -1
- package/dist/hooks/useUiNotice.js +13 -2
- package/dist/hooks/useWorkspaceSyncController.d.ts +9 -0
- package/dist/hooks/useWorkspaceSyncController.js +159 -17
- package/dist/hooks/workspace/usePersistedAppUiState.d.ts +74 -0
- package/dist/hooks/workspace/usePersistedAppUiState.js +325 -0
- package/dist/hooks/workspace/usePersistedLayoutUiState.d.ts +76 -0
- package/dist/hooks/workspace/usePersistedLayoutUiState.js +479 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +2 -7
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +42 -31
- package/dist/hooks/workspace/workspaceLocalState.d.ts +17 -6
- package/dist/hooks/workspace/workspaceLocalState.js +175 -5
- package/dist/hooks/workstreams/workstreamHttpMutation.d.ts +23 -0
- package/dist/hooks/workstreams/workstreamHttpMutation.js +134 -0
- package/dist/localization/locales/en-US.d.ts +3 -3
- package/dist/localization/locales/en-US.js +3 -3
- package/dist/localization/locales/es-419.js +3 -3
- package/dist/localization/locales/pt-BR.js +3 -3
- package/dist/mcp/adminWorkspaceRegistrar.js +2 -30
- package/dist/mcp/aiProfileBootstrap.d.ts +3 -0
- package/dist/mcp/aiProfileBootstrap.js +4 -0
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +104 -35
- package/dist/mcp/clientIntegrations.d.ts +4 -2
- package/dist/mcp/clientIntegrations.js +58 -57
- package/dist/mcp/clientRegistry.d.ts +30 -0
- package/dist/mcp/clientRegistry.js +134 -0
- package/dist/mcp/collaborationRegistrar.js +20 -6
- package/dist/mcp/documentAssetRegistrar.js +220 -52
- package/dist/mcp/documentContentCommand.d.ts +94 -0
- package/dist/mcp/documentContentCommand.js +226 -0
- package/dist/mcp/documentHelpers.d.ts +13 -65
- package/dist/mcp/documentHelpers.js +49 -62
- package/dist/mcp/durableTaskMutationRouter.d.ts +65 -0
- package/dist/mcp/durableTaskMutationRouter.js +217 -0
- package/dist/mcp/durableTaskRelationshipMutationRouter.d.ts +38 -0
- package/dist/mcp/durableTaskRelationshipMutationRouter.js +168 -0
- package/dist/mcp/durableTaskRootMutationRouter.d.ts +64 -0
- package/dist/mcp/durableTaskRootMutationRouter.js +226 -0
- package/dist/mcp/gitWorktreeRoots.d.ts +2 -0
- package/dist/mcp/gitWorktreeRoots.js +19 -0
- package/dist/mcp/legacyServer.d.ts +1 -0
- package/dist/mcp/legacyServer.js +42 -0
- package/dist/mcp/localCliHealth.d.ts +56 -0
- package/dist/mcp/localCliHealth.js +170 -0
- package/dist/mcp/localServiceProxy.d.ts +23 -0
- package/dist/mcp/localServiceProxy.js +255 -0
- package/dist/mcp/mcpInputValidator.d.ts +17 -0
- package/dist/mcp/mcpInputValidator.js +58 -0
- package/dist/mcp/planningAttachmentCommandAdapter.d.ts +127 -0
- package/dist/mcp/planningAttachmentCommandAdapter.js +188 -0
- package/dist/mcp/programScaffoldCommandAdapter.d.ts +6 -0
- package/dist/mcp/programScaffoldCommandAdapter.js +39 -0
- package/dist/mcp/proxyServer.d.ts +1 -0
- package/dist/mcp/proxyServer.js +2 -0
- package/dist/mcp/runtime.d.ts +82 -1
- package/dist/mcp/runtime.js +4285 -1925
- package/dist/mcp/server.js +29 -2
- package/dist/mcp/sharedLocalServiceGateD.d.ts +115 -0
- package/dist/mcp/sharedLocalServiceGateD.js +344 -0
- package/dist/mcp/stdioLifecycle.d.ts +22 -0
- package/dist/mcp/stdioLifecycle.js +54 -0
- package/dist/mcp/taskAttachmentCommandAdapter.d.ts +190 -0
- package/dist/mcp/taskAttachmentCommandAdapter.js +464 -0
- package/dist/mcp/taskAttachmentHelpers.d.ts +8 -6
- package/dist/mcp/taskAttachmentHelpers.js +74 -2
- package/dist/mcp/taskAttachmentTypes.d.ts +26 -5
- package/dist/mcp/taskPlanCommand.d.ts +64 -0
- package/dist/mcp/taskPlanCommand.js +98 -0
- package/dist/mcp/taskPlanningRegistrar.js +215 -27
- package/dist/mcp/toolCallCompatibility.d.ts +6 -0
- package/dist/mcp/toolCallCompatibility.js +16 -0
- package/dist/mcp/toolCatalog.d.ts +3 -1
- package/dist/mcp/toolProfiles.d.ts +12 -1
- package/dist/mcp/toolProfiles.js +43 -0
- package/dist/mcp/toolRegistry.d.ts +17 -2
- package/dist/mcp/toolRegistry.js +24 -9
- package/dist/mcp/workflowExecutionRegistrar.d.ts +2 -0
- package/dist/mcp/workflowExecutionRegistrar.js +51 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +35 -0
- package/dist/migrations/taskSchemaMigrations.js +1797 -4
- package/dist/migrations/taskUpdatedAtSyncDriftMigration.d.ts +20 -0
- package/dist/migrations/taskUpdatedAtSyncDriftMigration.js +153 -0
- package/dist/migrations/taskforceAgentDefinitionMigration.d.ts +26 -0
- package/dist/migrations/taskforceAgentDefinitionMigration.js +116 -0
- package/dist/plugins/vite.js +48 -17
- package/dist/public/Taskforce_Van.webp +0 -0
- package/dist/runtime/appGateDefinitions.d.ts +16 -10
- package/dist/runtime/appGateDefinitions.js +16 -10
- package/dist/runtime/appGates.d.ts +5 -4
- package/dist/runtime/appGates.js +4 -3
- package/dist/server/aiProfileAvatarAssets.d.ts +11 -8
- package/dist/server/aiProfileAvatarAssets.js +159 -70
- package/dist/server/auth.js +15 -1
- package/dist/server/cors.d.ts +2 -1
- package/dist/server/cors.js +45 -8
- package/dist/server/email.d.ts +3 -1
- package/dist/server/email.js +46 -8
- package/dist/server/index.d.ts +24 -1
- package/dist/server/index.js +342 -46
- package/dist/server/localDataOwnerPermit.d.ts +18 -0
- package/dist/server/localDataOwnerPermit.js +58 -0
- package/dist/server/localDatabaseIdentity.d.ts +14 -0
- package/dist/server/localDatabaseIdentity.js +69 -0
- package/dist/server/localRealtimeFileInvalidation.d.ts +1 -0
- package/dist/server/localRealtimeFileInvalidation.js +7 -0
- package/dist/server/localServiceLease.d.ts +62 -0
- package/dist/server/localServiceLease.js +477 -0
- package/dist/server/localServiceOwnershipBootstrap.d.ts +19 -0
- package/dist/server/localServiceOwnershipBootstrap.js +84 -0
- package/dist/server/localServiceSessions.d.ts +87 -0
- package/dist/server/localServiceSessions.js +309 -0
- package/dist/server/localServiceWindowsAcl.d.ts +8 -0
- package/dist/server/localServiceWindowsAcl.js +60 -0
- package/dist/server/localSyncProcessLockBootstrap.d.ts +3 -0
- package/dist/server/localSyncProcessLockBootstrap.js +16 -0
- package/dist/server/localWorkspaceSyncServerComposition.d.ts +59 -0
- package/dist/server/localWorkspaceSyncServerComposition.js +91 -0
- package/dist/server/localWorkspaceSyncServerRuntime.d.ts +119 -0
- package/dist/server/localWorkspaceSyncServerRuntime.js +1163 -0
- package/dist/server/rateLimit.d.ts +1 -0
- package/dist/server/rateLimit.js +16 -0
- package/dist/server/routes/admin.d.ts +2 -4
- package/dist/server/routes/admin.js +199 -337
- package/dist/server/routes/agentRoles.d.ts +13 -0
- package/dist/server/routes/agentRoles.js +273 -0
- package/dist/server/routes/agentSkills.d.ts +16 -0
- package/dist/server/routes/agentSkills.js +291 -0
- package/dist/server/routes/agents.d.ts +49 -0
- package/dist/server/routes/agents.js +1455 -0
- package/dist/server/routes/annotatedAttachments.js +46 -9
- package/dist/server/routes/assetAccess.d.ts +36 -0
- package/dist/server/routes/assetAccess.js +93 -0
- package/dist/server/routes/auth.d.ts +4 -3
- package/dist/server/routes/auth.js +236 -21
- package/dist/server/routes/authSupport.d.ts +3 -1
- package/dist/server/routes/authSupport.js +22 -8
- package/dist/server/routes/documentReviews.d.ts +2 -0
- package/dist/server/routes/documentReviews.js +155 -15
- package/dist/server/routes/documents.d.ts +1 -0
- package/dist/server/routes/documents.js +335 -141
- package/dist/server/routes/durableTaskHttpRouters.d.ts +165 -0
- package/dist/server/routes/durableTaskHttpRouters.js +89 -0
- package/dist/server/routes/localMcpHealth.d.ts +11 -0
- package/dist/server/routes/localMcpHealth.js +29 -0
- package/dist/server/routes/localService.d.ts +13 -0
- package/dist/server/routes/localService.js +444 -0
- package/dist/server/routes/localUpdate.d.ts +11 -0
- package/dist/server/routes/localUpdate.js +154 -0
- package/dist/server/routes/primitives.d.ts +27 -0
- package/dist/server/routes/primitives.js +18 -0
- package/dist/server/routes/resources.d.ts +1 -10
- package/dist/server/routes/resources.js +1 -153
- package/dist/server/routes/shared.d.ts +31 -2
- package/dist/server/routes/shared.js +394 -0
- package/dist/server/routes/sync.d.ts +2 -0
- package/dist/server/routes/sync.js +184 -272
- package/dist/server/routes/syncAuxRoutes.d.ts +3 -1
- package/dist/server/routes/syncAuxRoutes.js +9 -4
- package/dist/server/routes/syncCoordinatorLocalRoutes.d.ts +6 -0
- package/dist/server/routes/syncCoordinatorLocalRoutes.js +867 -0
- package/dist/server/routes/syncPullApplyRoutes.d.ts +29 -1
- package/dist/server/routes/syncPullApplyRoutes.js +520 -17
- package/dist/server/routes/syncPushRoutes.d.ts +33 -4
- package/dist/server/routes/syncPushRoutes.js +251 -62
- package/dist/server/routes/syncRouteTypes.d.ts +1 -1
- package/dist/server/routes/syncSnapshotRoutes.d.ts +15 -7
- package/dist/server/routes/syncSnapshotRoutes.js +95 -17
- package/dist/server/routes/syncV3DiagnosticsRoutes.d.ts +14 -0
- package/dist/server/routes/syncV3DiagnosticsRoutes.js +72 -0
- package/dist/server/routes/syncV3FeedRoutes.d.ts +20 -0
- package/dist/server/routes/syncV3FeedRoutes.js +462 -0
- package/dist/server/routes/syncV3LocalCaptureRoutes.d.ts +37 -0
- package/dist/server/routes/syncV3LocalCaptureRoutes.js +897 -0
- package/dist/server/routes/syncV3LocalDispatchRoutes.d.ts +17 -0
- package/dist/server/routes/syncV3LocalDispatchRoutes.js +280 -0
- package/dist/server/routes/syncV3LocalFeedApplyRoutes.d.ts +17 -0
- package/dist/server/routes/syncV3LocalFeedApplyRoutes.js +493 -0
- package/dist/server/routes/syncV3LocalOwnershipRoutes.d.ts +10 -0
- package/dist/server/routes/syncV3LocalOwnershipRoutes.js +51 -0
- package/dist/server/routes/syncV3LocalRecoveryRoutes.d.ts +12 -0
- package/dist/server/routes/syncV3LocalRecoveryRoutes.js +78 -0
- package/dist/server/routes/syncV3OperationRoutes.d.ts +35 -0
- package/dist/server/routes/syncV3OperationRoutes.js +1136 -0
- package/dist/server/routes/tasks.d.ts +0 -2
- package/dist/server/routes/tasks.js +932 -195
- package/dist/server/routes/workflows.d.ts +26 -0
- package/dist/server/routes/workflows.js +500 -0
- package/dist/server/routes.d.ts +1 -6
- package/dist/server/routes.js +148 -339
- package/dist/server/smtpTransport.d.ts +11 -3
- package/dist/server/smtpTransport.js +9 -2
- package/dist/server/start.js +42 -10
- package/dist/service/localServiceCli.d.ts +33 -0
- package/dist/service/localServiceCli.js +365 -0
- package/dist/service/localServiceClientLifecycle.d.ts +25 -0
- package/dist/service/localServiceClientLifecycle.js +128 -0
- package/dist/service/localServiceOpen.d.ts +17 -0
- package/dist/service/localServiceOpen.js +27 -0
- package/dist/services/agentRoleResolver.d.ts +4 -0
- package/dist/services/agentRoleResolver.js +7 -0
- package/dist/services/aiProfileAvatarGenerationService.d.ts +41 -0
- package/dist/services/aiProfileAvatarGenerationService.js +158 -0
- package/dist/services/avatarImageProvider.d.ts +33 -0
- package/dist/services/avatarImageProvider.js +140 -0
- package/dist/services/bedrockAnthropicMessagesModelGateway.d.ts +24 -0
- package/dist/services/bedrockAnthropicMessagesModelGateway.js +95 -0
- package/dist/services/bedrockMantleModelGateway.d.ts +43 -0
- package/dist/services/bedrockMantleModelGateway.js +81 -0
- package/dist/services/bedrockModelGateway.d.ts +26 -0
- package/dist/services/bedrockModelGateway.js +94 -0
- package/dist/services/modelGateway.d.ts +82 -0
- package/dist/services/modelGateway.js +61 -0
- package/dist/services/taskforceAgentAvatarImageProvider.d.ts +8 -0
- package/dist/services/taskforceAgentAvatarImageProvider.js +24 -0
- package/dist/services/taskforceAgentMcpBridge.d.ts +77 -0
- package/dist/services/taskforceAgentMcpBridge.js +527 -0
- package/dist/shared/agentRoleDefinition.d.ts +34 -0
- package/dist/shared/agentRoleDefinition.js +207 -0
- package/dist/shared/aiProfileAvatarEligibility.d.ts +10 -0
- package/dist/shared/aiProfileAvatarEligibility.js +27 -0
- package/dist/shared/aiProfileColors.d.ts +7 -0
- package/dist/shared/aiProfileColors.js +33 -0
- package/dist/shared/colorPalette.d.ts +3 -0
- package/dist/shared/colorPalette.js +20 -0
- package/dist/shared/commentProvenance.d.ts +4 -0
- package/dist/shared/commentProvenance.js +6 -0
- package/dist/shared/deletedTaskRetention.d.ts +2 -0
- package/dist/shared/deletedTaskRetention.js +7 -0
- package/dist/shared/passwordPolicy.d.ts +2 -1
- package/dist/shared/passwordPolicy.js +9 -0
- package/dist/shared/taskDeepLinks.d.ts +28 -0
- package/dist/shared/taskDeepLinks.js +48 -0
- package/dist/shared/taskRestoreDestination.d.ts +2 -0
- package/dist/shared/taskRestoreDestination.js +5 -0
- package/dist/shared/taskforceAgentDefinition.d.ts +88 -0
- package/dist/shared/taskforceAgentDefinition.js +434 -0
- package/dist/shared/taskforceAgentExecutionSnapshot.d.ts +50 -0
- package/dist/shared/taskforceAgentExecutionSnapshot.js +68 -0
- package/dist/shared/taskforceAgentModels.d.ts +56 -0
- package/dist/shared/taskforceAgentModels.js +314 -0
- package/dist/shared/taskforceAgentRunContext.d.ts +23 -0
- package/dist/shared/taskforceAgentRunContext.js +21 -0
- package/dist/shared/taskforceAgentRuntimeResolution.d.ts +31 -0
- package/dist/shared/taskforceAgentRuntimeResolution.js +72 -0
- package/dist/shared/taskforceAgentSkill.d.ts +33 -0
- package/dist/shared/taskforceAgentSkill.js +149 -0
- package/dist/storage/agentRoleStore.d.ts +91 -0
- package/dist/storage/agentRoleStore.js +385 -0
- package/dist/storage/databaseAdapter.d.ts +1 -0
- package/dist/storage/databaseAdapter.js +7 -1
- package/dist/storage/documentIntegrity.d.ts +12 -3
- package/dist/storage/documentIntegrity.js +105 -27
- package/dist/storage/objectStorageClient.d.ts +10 -4
- package/dist/storage/objectStorageClient.js +73 -43
- package/dist/storage/postgresAdapter.d.ts +2 -1
- package/dist/storage/postgresAdapter.js +38 -6
- package/dist/storage/postgresWorker.js +65 -1
- package/dist/storage/sqliteAdapter.d.ts +1 -1
- package/dist/storage/sqliteAdapter.js +7 -1
- package/dist/storage/sqliteWalHealth.d.ts +71 -0
- package/dist/storage/sqliteWalHealth.js +176 -0
- package/dist/storage/taskDataPath.d.ts +1 -0
- package/dist/storage/taskDataPath.js +20 -0
- package/dist/storage/taskforceAgentConversationStore.d.ts +66 -0
- package/dist/storage/taskforceAgentConversationStore.js +264 -0
- package/dist/storage/taskforceAgentSkillStore.d.ts +93 -0
- package/dist/storage/taskforceAgentSkillStore.js +355 -0
- package/dist/storage/taskforceAgentStore.d.ts +105 -0
- package/dist/storage/taskforceAgentStore.js +549 -0
- package/dist/storage/workflowStore.d.ts +223 -0
- package/dist/storage/workflowStore.js +1032 -0
- package/dist/storage/workspaceAssetStore.d.ts +70 -0
- package/dist/storage/workspaceAssetStore.js +418 -25
- package/dist/sync/aiProfileAvatarSyncPaths.d.ts +14 -0
- package/dist/sync/aiProfileAvatarSyncPaths.js +56 -0
- package/dist/sync/cloudSyncApi.d.ts +329 -0
- package/dist/sync/cloudSyncApi.js +574 -1
- package/dist/sync/collaborationSyncPayload.d.ts +5 -1
- package/dist/sync/collaborationSyncPayload.js +213 -1
- package/dist/sync/collaborationSyncState.d.ts +23 -3
- package/dist/sync/collaborationSyncState.js +92 -3
- package/dist/sync/contentSyncPayload.d.ts +9 -0
- package/dist/sync/contentSyncPayload.js +29 -0
- package/dist/sync/contentSyncState.d.ts +79 -4
- package/dist/sync/contentSyncState.js +1113 -313
- package/dist/sync/coordinator/localSyncBrowserApplyPermitContext.d.ts +39 -0
- package/dist/sync/coordinator/localSyncBrowserApplyPermitContext.js +234 -0
- package/dist/sync/coordinator/localSyncBrowserAuthorityCapability.d.ts +7 -0
- package/dist/sync/coordinator/localSyncBrowserAuthorityCapability.js +10 -0
- package/dist/sync/coordinator/localSyncCommittedApplyPolicy.d.ts +7 -0
- package/dist/sync/coordinator/localSyncCommittedApplyPolicy.js +41 -0
- package/dist/sync/coordinator/localSyncCoordinatorBrowserClient.d.ts +178 -0
- package/dist/sync/coordinator/localSyncCoordinatorBrowserClient.js +131 -0
- package/dist/sync/coordinator/localSyncCoordinatorCommandLifecycle.d.ts +25 -0
- package/dist/sync/coordinator/localSyncCoordinatorCommandLifecycle.js +200 -0
- package/dist/sync/coordinator/localSyncCoordinatorHost.d.ts +84 -0
- package/dist/sync/coordinator/localSyncCoordinatorHost.js +435 -0
- package/dist/sync/coordinator/localSyncCoordinatorRouteServices.d.ts +33 -0
- package/dist/sync/coordinator/localSyncCoordinatorRouteServices.js +1 -0
- package/dist/sync/coordinator/localSyncCoordinatorStore.d.ts +122 -0
- package/dist/sync/coordinator/localSyncCoordinatorStore.js +1039 -0
- package/dist/sync/coordinator/localSyncCoordinatorTypes.d.ts +82 -0
- package/dist/sync/coordinator/localSyncCoordinatorTypes.js +9 -0
- package/dist/sync/coordinator/localSyncDispatchTracker.d.ts +15 -0
- package/dist/sync/coordinator/localSyncDispatchTracker.js +68 -0
- package/dist/sync/coordinator/localSyncExecutionPermitStore.d.ts +200 -0
- package/dist/sync/coordinator/localSyncExecutionPermitStore.js +1388 -0
- package/dist/sync/coordinator/localSyncExecutionPermitTypes.d.ts +52 -0
- package/dist/sync/coordinator/localSyncExecutionPermitTypes.js +8 -0
- package/dist/sync/coordinator/localSyncOwnershipDrainBarrier.d.ts +33 -0
- package/dist/sync/coordinator/localSyncOwnershipDrainBarrier.js +83 -0
- package/dist/sync/coordinator/localSyncPermitTransactionHooks.d.ts +9 -0
- package/dist/sync/coordinator/localSyncPermitTransactionHooks.js +27 -0
- package/dist/sync/coordinator/localSyncProcessLock.d.ts +12 -0
- package/dist/sync/coordinator/localSyncProcessLock.js +212 -0
- package/dist/sync/coordinator/localSyncV3DispatchRecoveryReconciler.d.ts +17 -0
- package/dist/sync/coordinator/localSyncV3DispatchRecoveryReconciler.js +82 -0
- package/dist/sync/coordinator/localWorkspaceSyncRunner.d.ts +134 -0
- package/dist/sync/coordinator/localWorkspaceSyncRunner.js +981 -0
- package/dist/sync/coordinator/localWorkspaceSyncRunnerState.d.ts +189 -0
- package/dist/sync/coordinator/localWorkspaceSyncRunnerState.js +961 -0
- package/dist/sync/coordinator/workspaceSyncCloudCredentialValidator.d.ts +19 -0
- package/dist/sync/coordinator/workspaceSyncCloudCredentialValidator.js +83 -0
- package/dist/sync/coordinator/workspaceSyncCredentialBroker.d.ts +117 -0
- package/dist/sync/coordinator/workspaceSyncCredentialBroker.js +323 -0
- package/dist/sync/coordinator/workspaceSyncLocalAccessBootstrap.d.ts +11 -0
- package/dist/sync/coordinator/workspaceSyncLocalAccessBootstrap.js +51 -0
- package/dist/sync/coordinator/workspaceSyncTransportGateway.d.ts +139 -0
- package/dist/sync/coordinator/workspaceSyncTransportGateway.js +738 -0
- package/dist/sync/engine/localWorkspaceContentManifestService.d.ts +21 -0
- package/dist/sync/engine/localWorkspaceContentManifestService.js +44 -0
- package/dist/sync/engine/workspaceSyncAtomicLocalPublisher.d.ts +36 -0
- package/dist/sync/engine/workspaceSyncAtomicLocalPublisher.js +203 -0
- package/dist/sync/engine/workspaceSyncContentLifecycleAdapter.d.ts +21 -0
- package/dist/sync/engine/workspaceSyncContentLifecycleAdapter.js +13 -0
- package/dist/sync/engine/workspaceSyncContentPullObligationStore.d.ts +65 -0
- package/dist/sync/engine/workspaceSyncContentPullObligationStore.js +160 -0
- package/dist/sync/engine/workspaceSyncContentVersionStore.d.ts +216 -0
- package/dist/sync/engine/workspaceSyncContentVersionStore.js +1068 -0
- package/dist/sync/engine/workspaceSyncEngineBrowserGatewayRemoteServices.d.ts +28 -0
- package/dist/sync/engine/workspaceSyncEngineBrowserGatewayRemoteServices.js +543 -0
- package/dist/sync/engine/workspaceSyncEngineContracts.d.ts +78 -0
- package/dist/sync/engine/workspaceSyncEngineContracts.js +1 -0
- package/dist/sync/engine/workspaceSyncEngineControlPlane.d.ts +37 -0
- package/dist/sync/engine/workspaceSyncEngineControlPlane.js +79 -0
- package/dist/sync/engine/workspaceSyncEngineIdentity.d.ts +1 -0
- package/dist/sync/engine/workspaceSyncEngineIdentity.js +4 -0
- package/dist/sync/engine/workspaceSyncEngineLifecyclePolicy.d.ts +68 -0
- package/dist/sync/engine/workspaceSyncEngineLifecyclePolicy.js +180 -0
- package/dist/sync/engine/workspaceSyncEngineLocalApply.d.ts +72 -0
- package/dist/sync/engine/workspaceSyncEngineLocalApply.js +132 -0
- package/dist/sync/engine/workspaceSyncEngineLocalServices.d.ts +70 -0
- package/dist/sync/engine/workspaceSyncEngineLocalServices.js +25 -0
- package/dist/sync/engine/workspaceSyncEngineParity.d.ts +8 -0
- package/dist/sync/engine/workspaceSyncEngineParity.js +7 -0
- package/dist/sync/engine/workspaceSyncEnginePullPolicy.d.ts +32 -0
- package/dist/sync/engine/workspaceSyncEnginePullPolicy.js +27 -0
- package/dist/sync/engine/workspaceSyncEnginePushCompletion.d.ts +20 -0
- package/dist/sync/engine/workspaceSyncEnginePushCompletion.js +87 -0
- package/dist/sync/engine/workspaceSyncEngineRecovery.d.ts +56 -0
- package/dist/sync/engine/workspaceSyncEngineRecovery.js +288 -0
- package/dist/sync/engine/workspaceSyncEngineRemoteServices.d.ts +104 -0
- package/dist/sync/engine/workspaceSyncEngineRemoteServices.js +21 -0
- package/dist/sync/engine/workspaceSyncEngineRuntime.d.ts +61 -0
- package/dist/sync/engine/workspaceSyncEngineRuntime.js +59 -0
- package/dist/sync/engine/workspaceSyncEngineServerApplyBoundary.d.ts +28 -0
- package/dist/sync/engine/workspaceSyncEngineServerApplyBoundary.js +86 -0
- package/dist/sync/engine/workspaceSyncEngineServerLocalServices.d.ts +73 -0
- package/dist/sync/engine/workspaceSyncEngineServerLocalServices.js +452 -0
- package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.d.ts +23 -0
- package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.js +451 -0
- package/dist/sync/engine/workspaceSyncEngineServerRunnerOperations.d.ts +125 -0
- package/dist/sync/engine/workspaceSyncEngineServerRunnerOperations.js +1124 -0
- package/dist/sync/engine/workspaceSyncEngineServerSnapshotReader.d.ts +63 -0
- package/dist/sync/engine/workspaceSyncEngineServerSnapshotReader.js +276 -0
- package/dist/sync/engine/workspaceSyncEngineTransferCompletion.d.ts +20 -0
- package/dist/sync/engine/workspaceSyncEngineTransferCompletion.js +37 -0
- package/dist/sync/engine/workspaceSyncEngineTransferTypes.d.ts +7 -0
- package/dist/sync/engine/workspaceSyncEngineTransferTypes.js +1 -0
- package/dist/sync/engine/workspaceSyncEngineTransfers.d.ts +240 -0
- package/dist/sync/engine/workspaceSyncEngineTransfers.js +1184 -0
- package/dist/sync/engine/workspaceSyncEngineV3LocalFeedApply.d.ts +39 -0
- package/dist/sync/engine/workspaceSyncEngineV3LocalFeedApply.js +39 -0
- package/dist/sync/engine/workspaceSyncImmutableContentGcRunner.d.ts +25 -0
- package/dist/sync/engine/workspaceSyncImmutableContentGcRunner.js +83 -0
- package/dist/sync/engine/workspaceSyncImmutableContentProvider.d.ts +71 -0
- package/dist/sync/engine/workspaceSyncImmutableContentProvider.js +209 -0
- package/dist/sync/engine/workspaceSyncImmutableContentStager.d.ts +32 -0
- package/dist/sync/engine/workspaceSyncImmutableContentStager.js +202 -0
- package/dist/sync/engine/workspaceSyncPublishedContentResolver.d.ts +43 -0
- package/dist/sync/engine/workspaceSyncPublishedContentResolver.js +142 -0
- package/dist/sync/engine/workspaceSyncServerContentRuntime.d.ts +26 -0
- package/dist/sync/engine/workspaceSyncServerContentRuntime.js +41 -0
- package/dist/sync/engine/workspaceSyncServerDataServices.d.ts +44 -0
- package/dist/sync/engine/workspaceSyncServerDataServices.js +143 -0
- package/dist/sync/engine/workspaceSyncTransferEvidence.d.ts +39 -0
- package/dist/sync/engine/workspaceSyncTransferEvidence.js +45 -0
- package/dist/sync/engine/workspaceSyncV2ChangeDispatcher.d.ts +44 -0
- package/dist/sync/engine/workspaceSyncV2ChangeDispatcher.js +990 -0
- package/dist/sync/engine/workspaceSyncV2PreparedContent.d.ts +82 -0
- package/dist/sync/engine/workspaceSyncV2PreparedContent.js +219 -0
- package/dist/sync/planningCanonicalFields.d.ts +41 -0
- package/dist/sync/planningCanonicalFields.js +211 -0
- package/dist/sync/planningSyncPayload.d.ts +5 -1
- package/dist/sync/planningSyncPayload.js +88 -4
- package/dist/sync/syncApplyHandlers.d.ts +98 -8
- package/dist/sync/syncApplyHandlers.js +816 -78
- package/dist/sync/syncResourceAdapters.d.ts +10 -0
- package/dist/sync/syncResourceAdapters.js +21 -1
- package/dist/sync/syncService.d.ts +57 -1
- package/dist/sync/syncService.js +1085 -138
- package/dist/sync/taskNestedContent.d.ts +2 -0
- package/dist/sync/taskNestedContent.js +8 -0
- package/dist/sync/taskSyncChangeKey.js +0 -1
- package/dist/sync/taskSyncPayload.d.ts +0 -1
- package/dist/sync/taskSyncPayload.js +14 -3
- package/dist/sync/v3/canonicalWorkspaceSyncJson.d.ts +2 -0
- package/dist/sync/v3/canonicalWorkspaceSyncJson.js +26 -0
- package/dist/sync/v3/combinedFeedCapability.d.ts +21 -0
- package/dist/sync/v3/combinedFeedCapability.js +80 -0
- package/dist/sync/v3/combinedFeedPrerequisites.d.ts +18 -0
- package/dist/sync/v3/combinedFeedPrerequisites.js +184 -0
- package/dist/sync/v3/durableInitiativeMutationRouter.d.ts +61 -0
- package/dist/sync/v3/durableInitiativeMutationRouter.js +287 -0
- package/dist/sync/v3/durableOperationReplay.d.ts +36 -0
- package/dist/sync/v3/durableOperationReplay.js +103 -0
- package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.d.ts +49 -0
- package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.js +198 -0
- package/dist/sync/v3/durableTaskChecklistMutationRouter.d.ts +47 -0
- package/dist/sync/v3/durableTaskChecklistMutationRouter.js +182 -0
- package/dist/sync/v3/durableTaskCommentMutationRouter.d.ts +47 -0
- package/dist/sync/v3/durableTaskCommentMutationRouter.js +191 -0
- package/dist/sync/v3/durableTaskHttpBulkMutationRouter.d.ts +60 -0
- package/dist/sync/v3/durableTaskHttpBulkMutationRouter.js +320 -0
- package/dist/sync/v3/durableTaskHttpRootMutationRouter.d.ts +40 -0
- package/dist/sync/v3/durableTaskHttpRootMutationRouter.js +518 -0
- package/dist/sync/v3/durableTaxonomyMutationRouter.d.ts +63 -0
- package/dist/sync/v3/durableTaxonomyMutationRouter.js +289 -0
- package/dist/sync/v3/durableWorkflowRuntimeMutationRouter.d.ts +77 -0
- package/dist/sync/v3/durableWorkflowRuntimeMutationRouter.js +361 -0
- package/dist/sync/v3/durableWorkstreamMutationRouter.d.ts +61 -0
- package/dist/sync/v3/durableWorkstreamMutationRouter.js +287 -0
- package/dist/sync/v3/durableWorkstreamTaskOrderMutationRouter.d.ts +42 -0
- package/dist/sync/v3/durableWorkstreamTaskOrderMutationRouter.js +122 -0
- package/dist/sync/v3/entityEventLegacyAdoptionContract.d.ts +6 -0
- package/dist/sync/v3/entityEventLegacyAdoptionContract.js +6 -0
- package/dist/sync/v3/entityEventLegacyAdoptionService.d.ts +62 -0
- package/dist/sync/v3/entityEventLegacyAdoptionService.js +180 -0
- package/dist/sync/v3/initiativeActivityAuthoritativeRevision.d.ts +11 -0
- package/dist/sync/v3/initiativeActivityAuthoritativeRevision.js +23 -0
- package/dist/sync/v3/initiativeActivityIdentity.d.ts +8 -0
- package/dist/sync/v3/initiativeActivityIdentity.js +14 -0
- package/dist/sync/v3/initiativeCoexistence.d.ts +26 -0
- package/dist/sync/v3/initiativeCoexistence.js +148 -0
- package/dist/sync/v3/initiativeFeedActivationCapability.d.ts +6 -0
- package/dist/sync/v3/initiativeFeedActivationCapability.js +16 -0
- package/dist/sync/v3/initiativeMutationPayload.d.ts +26 -0
- package/dist/sync/v3/initiativeMutationPayload.js +144 -0
- package/dist/sync/v3/initiativeMutationService.d.ts +62 -0
- package/dist/sync/v3/initiativeMutationService.js +206 -0
- package/dist/sync/v3/localEntityEventLegacyAdoption.d.ts +31 -0
- package/dist/sync/v3/localEntityEventLegacyAdoption.js +607 -0
- package/dist/sync/v3/localInitiativeOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localInitiativeOutboxHandler.js +88 -0
- package/dist/sync/v3/localInitiativeOutboxService.d.ts +50 -0
- package/dist/sync/v3/localInitiativeOutboxService.js +495 -0
- package/dist/sync/v3/localOperationIdentityStore.d.ts +81 -0
- package/dist/sync/v3/localOperationIdentityStore.js +388 -0
- package/dist/sync/v3/localOutboxDispatchCapability.d.ts +7 -0
- package/dist/sync/v3/localOutboxDispatchCapability.js +13 -0
- package/dist/sync/v3/localOutboxDispatchRuntime.d.ts +16 -0
- package/dist/sync/v3/localOutboxDispatchRuntime.js +269 -0
- package/dist/sync/v3/localOutboxDispatcher.d.ts +53 -0
- package/dist/sync/v3/localOutboxDispatcher.js +362 -0
- package/dist/sync/v3/localOutboxDrainClient.d.ts +50 -0
- package/dist/sync/v3/localOutboxDrainClient.js +250 -0
- package/dist/sync/v3/localOutboxOperationHandler.d.ts +31 -0
- package/dist/sync/v3/localOutboxOperationHandler.js +10 -0
- package/dist/sync/v3/localOutboxRecoveryClient.d.ts +14 -0
- package/dist/sync/v3/localOutboxRecoveryClient.js +45 -0
- package/dist/sync/v3/localOutboxRecoveryPreparationService.d.ts +12 -0
- package/dist/sync/v3/localOutboxRecoveryPreparationService.js +151 -0
- package/dist/sync/v3/localSyncIdentityStore.d.ts +9 -0
- package/dist/sync/v3/localSyncIdentityStore.js +43 -0
- package/dist/sync/v3/localTaskAttachmentLinksOutboxHandler.d.ts +10 -0
- package/dist/sync/v3/localTaskAttachmentLinksOutboxHandler.js +70 -0
- package/dist/sync/v3/localTaskAttachmentLinksOutboxService.d.ts +45 -0
- package/dist/sync/v3/localTaskAttachmentLinksOutboxService.js +291 -0
- package/dist/sync/v3/localTaskChecklistOutboxHandler.d.ts +10 -0
- package/dist/sync/v3/localTaskChecklistOutboxHandler.js +54 -0
- package/dist/sync/v3/localTaskChecklistOutboxService.d.ts +47 -0
- package/dist/sync/v3/localTaskChecklistOutboxService.js +221 -0
- package/dist/sync/v3/localTaskCommentOutboxHandler.d.ts +10 -0
- package/dist/sync/v3/localTaskCommentOutboxHandler.js +94 -0
- package/dist/sync/v3/localTaskCommentOutboxService.d.ts +75 -0
- package/dist/sync/v3/localTaskCommentOutboxService.js +391 -0
- package/dist/sync/v3/localTaskCreateClient.d.ts +27 -0
- package/dist/sync/v3/localTaskCreateClient.js +209 -0
- package/dist/sync/v3/localTaskCreateOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskCreateOutboxHandler.js +147 -0
- package/dist/sync/v3/localTaskCreateOutboxService.d.ts +77 -0
- package/dist/sync/v3/localTaskCreateOutboxService.js +397 -0
- package/dist/sync/v3/localTaskDeleteClient.d.ts +10 -0
- package/dist/sync/v3/localTaskDeleteClient.js +23 -0
- package/dist/sync/v3/localTaskDeleteOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskDeleteOutboxHandler.js +54 -0
- package/dist/sync/v3/localTaskDeleteOutboxService.d.ts +38 -0
- package/dist/sync/v3/localTaskDeleteOutboxService.js +235 -0
- package/dist/sync/v3/localTaskLegacyRootAdoption.d.ts +28 -0
- package/dist/sync/v3/localTaskLegacyRootAdoption.js +219 -0
- package/dist/sync/v3/localTaskLifecycleClient.d.ts +49 -0
- package/dist/sync/v3/localTaskLifecycleClient.js +57 -0
- package/dist/sync/v3/localTaskLifecycleOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskLifecycleOutboxHandler.js +94 -0
- package/dist/sync/v3/localTaskLifecycleOutboxService.d.ts +60 -0
- package/dist/sync/v3/localTaskLifecycleOutboxService.js +304 -0
- package/dist/sync/v3/localTaskMetadataClient.d.ts +13 -0
- package/dist/sync/v3/localTaskMetadataClient.js +30 -0
- package/dist/sync/v3/localTaskMetadataOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskMetadataOutboxHandler.js +70 -0
- package/dist/sync/v3/localTaskMetadataOutboxService.d.ts +48 -0
- package/dist/sync/v3/localTaskMetadataOutboxService.js +229 -0
- package/dist/sync/v3/localTaskOperationClient.d.ts +72 -0
- package/dist/sync/v3/localTaskOperationClient.js +195 -0
- package/dist/sync/v3/localTaskRelationshipOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskRelationshipOutboxHandler.js +114 -0
- package/dist/sync/v3/localTaskRelationshipOutboxService.d.ts +45 -0
- package/dist/sync/v3/localTaskRelationshipOutboxService.js +192 -0
- package/dist/sync/v3/localTaskRestoreClient.d.ts +13 -0
- package/dist/sync/v3/localTaskRestoreClient.js +32 -0
- package/dist/sync/v3/localTaskRestoreOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskRestoreOutboxHandler.js +41 -0
- package/dist/sync/v3/localTaskRestoreOutboxService.d.ts +56 -0
- package/dist/sync/v3/localTaskRestoreOutboxService.js +323 -0
- package/dist/sync/v3/localTaskStatusClient.d.ts +17 -0
- package/dist/sync/v3/localTaskStatusClient.js +35 -0
- package/dist/sync/v3/localTaskStatusOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localTaskStatusOutboxHandler.js +75 -0
- package/dist/sync/v3/localTaskStatusOutboxService.d.ts +53 -0
- package/dist/sync/v3/localTaskStatusOutboxService.js +422 -0
- package/dist/sync/v3/localTaxonomyCompositeOutboxService.d.ts +34 -0
- package/dist/sync/v3/localTaxonomyCompositeOutboxService.js +290 -0
- package/dist/sync/v3/localTaxonomyOutboxHandler.d.ts +9 -0
- package/dist/sync/v3/localTaxonomyOutboxHandler.js +106 -0
- package/dist/sync/v3/localTaxonomyOutboxService.d.ts +30 -0
- package/dist/sync/v3/localTaxonomyOutboxService.js +181 -0
- package/dist/sync/v3/localWorkflowRuntimeOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localWorkflowRuntimeOutboxHandler.js +113 -0
- package/dist/sync/v3/localWorkflowRuntimeOutboxService.d.ts +37 -0
- package/dist/sync/v3/localWorkflowRuntimeOutboxService.js +330 -0
- package/dist/sync/v3/localWorkspaceSyncServerOutboxDispatcher.d.ts +63 -0
- package/dist/sync/v3/localWorkspaceSyncServerOutboxDispatcher.js +264 -0
- package/dist/sync/v3/localWorkstreamOutboxHandler.d.ts +8 -0
- package/dist/sync/v3/localWorkstreamOutboxHandler.js +102 -0
- package/dist/sync/v3/localWorkstreamOutboxService.d.ts +52 -0
- package/dist/sync/v3/localWorkstreamOutboxService.js +579 -0
- package/dist/sync/v3/localWorkstreamTaskOrderOutboxHandler.d.ts +10 -0
- package/dist/sync/v3/localWorkstreamTaskOrderOutboxHandler.js +57 -0
- package/dist/sync/v3/localWorkstreamTaskOrderOutboxService.d.ts +30 -0
- package/dist/sync/v3/localWorkstreamTaskOrderOutboxService.js +128 -0
- package/dist/sync/v3/planningActivation.d.ts +13 -0
- package/dist/sync/v3/planningActivation.js +33 -0
- package/dist/sync/v3/planningAttachmentActivityValidation.d.ts +9 -0
- package/dist/sync/v3/planningAttachmentActivityValidation.js +87 -0
- package/dist/sync/v3/programScaffoldService.d.ts +143 -0
- package/dist/sync/v3/programScaffoldService.js +478 -0
- package/dist/sync/v3/syncDurabilityStore.d.ts +773 -0
- package/dist/sync/v3/syncDurabilityStore.js +4635 -0
- package/dist/sync/v3/taskActivityAuthoritativeRevision.d.ts +16 -0
- package/dist/sync/v3/taskActivityAuthoritativeRevision.js +67 -0
- package/dist/sync/v3/taskActivityJournal.d.ts +20 -0
- package/dist/sync/v3/taskActivityJournal.js +195 -0
- package/dist/sync/v3/taskAttachmentLinksActivityIdentity.d.ts +6 -0
- package/dist/sync/v3/taskAttachmentLinksActivityIdentity.js +14 -0
- package/dist/sync/v3/taskAttachmentLinksMutationService.d.ts +111 -0
- package/dist/sync/v3/taskAttachmentLinksMutationService.js +202 -0
- package/dist/sync/v3/taskChecklistActivityIdentity.d.ts +6 -0
- package/dist/sync/v3/taskChecklistActivityIdentity.js +14 -0
- package/dist/sync/v3/taskChecklistMutationService.d.ts +92 -0
- package/dist/sync/v3/taskChecklistMutationService.js +156 -0
- package/dist/sync/v3/taskCommentActivityIdentity.d.ts +7 -0
- package/dist/sync/v3/taskCommentActivityIdentity.js +15 -0
- package/dist/sync/v3/taskCommentMutationService.d.ts +150 -0
- package/dist/sync/v3/taskCommentMutationService.js +392 -0
- package/dist/sync/v3/taskCommentReconcileProvenance.d.ts +58 -0
- package/dist/sync/v3/taskCommentReconcileProvenance.js +170 -0
- package/dist/sync/v3/taskCreateActivation.d.ts +19 -0
- package/dist/sync/v3/taskCreateActivation.js +84 -0
- package/dist/sync/v3/taskCreateActivityIdentity.d.ts +7 -0
- package/dist/sync/v3/taskCreateActivityIdentity.js +18 -0
- package/dist/sync/v3/taskCreateMutationService.d.ts +103 -0
- package/dist/sync/v3/taskCreateMutationService.js +283 -0
- package/dist/sync/v3/taskDeleteActivation.d.ts +7 -0
- package/dist/sync/v3/taskDeleteActivation.js +14 -0
- package/dist/sync/v3/taskDeleteMutationService.d.ts +35 -0
- package/dist/sync/v3/taskDeleteMutationService.js +133 -0
- package/dist/sync/v3/taskLegacyRootAdoptionService.d.ts +78 -0
- package/dist/sync/v3/taskLegacyRootAdoptionService.js +175 -0
- package/dist/sync/v3/taskLifecycleActivityIdentity.d.ts +9 -0
- package/dist/sync/v3/taskLifecycleActivityIdentity.js +18 -0
- package/dist/sync/v3/taskLifecycleMutationPayload.d.ts +5 -0
- package/dist/sync/v3/taskLifecycleMutationPayload.js +28 -0
- package/dist/sync/v3/taskLifecycleMutationService.d.ts +66 -0
- package/dist/sync/v3/taskLifecycleMutationService.js +92 -0
- package/dist/sync/v3/taskMetadataActivation.d.ts +12 -0
- package/dist/sync/v3/taskMetadataActivation.js +38 -0
- package/dist/sync/v3/taskMetadataActivityIdentity.d.ts +7 -0
- package/dist/sync/v3/taskMetadataActivityIdentity.js +18 -0
- package/dist/sync/v3/taskMetadataCoexistence.d.ts +37 -0
- package/dist/sync/v3/taskMetadataCoexistence.js +134 -0
- package/dist/sync/v3/taskMetadataMutationService.d.ts +71 -0
- package/dist/sync/v3/taskMetadataMutationService.js +199 -0
- package/dist/sync/v3/taskRelationshipLegacyAdoptionService.d.ts +48 -0
- package/dist/sync/v3/taskRelationshipLegacyAdoptionService.js +164 -0
- package/dist/sync/v3/taskRelationshipMutationService.d.ts +50 -0
- package/dist/sync/v3/taskRelationshipMutationService.js +148 -0
- package/dist/sync/v3/taskRestoreMutationService.d.ts +90 -0
- package/dist/sync/v3/taskRestoreMutationService.js +179 -0
- package/dist/sync/v3/taskStatusActivation.d.ts +7 -0
- package/dist/sync/v3/taskStatusActivation.js +15 -0
- package/dist/sync/v3/taskStatusMutationService.d.ts +76 -0
- package/dist/sync/v3/taskStatusMutationService.js +308 -0
- package/dist/sync/v3/taskWorkflowMutationPayload.d.ts +4 -0
- package/dist/sync/v3/taskWorkflowMutationPayload.js +22 -0
- package/dist/sync/v3/taskWorkflowMutationService.d.ts +64 -0
- package/dist/sync/v3/taskWorkflowMutationService.js +102 -0
- package/dist/sync/v3/taxonomyCoexistence.d.ts +10 -0
- package/dist/sync/v3/taxonomyCoexistence.js +19 -0
- package/dist/sync/v3/taxonomyCompositeMutationService.d.ts +110 -0
- package/dist/sync/v3/taxonomyCompositeMutationService.js +304 -0
- package/dist/sync/v3/taxonomyMutationService.d.ts +57 -0
- package/dist/sync/v3/taxonomyMutationService.js +106 -0
- package/dist/sync/v3/taxonomyStateMutation.d.ts +22 -0
- package/dist/sync/v3/taxonomyStateMutation.js +49 -0
- package/dist/sync/v3/taxonomySyncPayload.d.ts +9 -0
- package/dist/sync/v3/taxonomySyncPayload.js +199 -0
- package/dist/sync/v3/workflowAssignmentSync.d.ts +22 -0
- package/dist/sync/v3/workflowAssignmentSync.js +213 -0
- package/dist/sync/v3/workflowRuntimeMutationService.d.ts +89 -0
- package/dist/sync/v3/workflowRuntimeMutationService.js +359 -0
- package/dist/sync/v3/workflowRuntimeOperationIdentity.d.ts +13 -0
- package/dist/sync/v3/workflowRuntimeOperationIdentity.js +35 -0
- package/dist/sync/v3/workflowRuntimeSync.d.ts +141 -0
- package/dist/sync/v3/workflowRuntimeSync.js +485 -0
- package/dist/sync/v3/workspaceSyncJournalReader.d.ts +43 -0
- package/dist/sync/v3/workspaceSyncJournalReader.js +45 -0
- package/dist/sync/v3/workspaceSyncLocalOwnershipSnapshot.d.ts +28 -0
- package/dist/sync/v3/workspaceSyncLocalOwnershipSnapshot.js +81 -0
- package/dist/sync/v3/workspaceSyncPayloadFingerprint.d.ts +1 -0
- package/dist/sync/v3/workspaceSyncPayloadFingerprint.js +5 -0
- package/dist/sync/v3/workspaceSyncV2ProjectionExclusions.d.ts +16 -0
- package/dist/sync/v3/workspaceSyncV2ProjectionExclusions.js +161 -0
- package/dist/sync/v3/workspaceSyncV3AttachmentApplyObligationStore.d.ts +83 -0
- package/dist/sync/v3/workspaceSyncV3AttachmentApplyObligationStore.js +161 -0
- package/dist/sync/v3/workspaceSyncV3AttachmentAssetHydration.d.ts +16 -0
- package/dist/sync/v3/workspaceSyncV3AttachmentAssetHydration.js +90 -0
- package/dist/sync/v3/workspaceSyncV3CloudOperationHandler.d.ts +65 -0
- package/dist/sync/v3/workspaceSyncV3CloudOperationHandler.js +356 -0
- package/dist/sync/v3/workspaceSyncV3CombinedCoverage.d.ts +51 -0
- package/dist/sync/v3/workspaceSyncV3CombinedCoverage.js +171 -0
- package/dist/sync/v3/workspaceSyncV3CombinedFeedOrchestrator.d.ts +43 -0
- package/dist/sync/v3/workspaceSyncV3CombinedFeedOrchestrator.js +535 -0
- package/dist/sync/v3/workspaceSyncV3CombinedLocalApply.d.ts +50 -0
- package/dist/sync/v3/workspaceSyncV3CombinedLocalApply.js +1149 -0
- package/dist/sync/v3/workspaceSyncV3CombinedProjectionState.d.ts +96 -0
- package/dist/sync/v3/workspaceSyncV3CombinedProjectionState.js +672 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.d.ts +42 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.js +598 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.d.ts +65 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.js +505 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairSnapshot.d.ts +78 -0
- package/dist/sync/v3/workspaceSyncV3CombinedRepairSnapshot.js +511 -0
- package/dist/sync/v3/workspaceSyncV3CombinedWireContract.d.ts +45 -0
- package/dist/sync/v3/workspaceSyncV3CombinedWireContract.js +125 -0
- package/dist/sync/v3/workspaceSyncV3CoverageRegistry.d.ts +13 -0
- package/dist/sync/v3/workspaceSyncV3CoverageRegistry.js +34 -0
- package/dist/sync/v3/workspaceSyncV3CursorEncoding.d.ts +1 -0
- package/dist/sync/v3/workspaceSyncV3CursorEncoding.js +6 -0
- package/dist/sync/v3/workspaceSyncV3DomainAdmission.d.ts +43 -0
- package/dist/sync/v3/workspaceSyncV3DomainAdmission.js +118 -0
- package/dist/sync/v3/workspaceSyncV3Feed.d.ts +95 -0
- package/dist/sync/v3/workspaceSyncV3Feed.js +243 -0
- package/dist/sync/v3/workspaceSyncV3FeedApply.d.ts +13 -0
- package/dist/sync/v3/workspaceSyncV3FeedApply.js +67 -0
- package/dist/sync/v3/workspaceSyncV3FeedClientContract.d.ts +3 -0
- package/dist/sync/v3/workspaceSyncV3FeedClientContract.js +46 -0
- package/dist/sync/v3/workspaceSyncV3FeedCursorKeys.d.ts +10 -0
- package/dist/sync/v3/workspaceSyncV3FeedCursorKeys.js +58 -0
- package/dist/sync/v3/workspaceSyncV3FeedOrchestrator.d.ts +31 -0
- package/dist/sync/v3/workspaceSyncV3FeedOrchestrator.js +228 -0
- package/dist/sync/v3/workspaceSyncV3FeedProfile.d.ts +32 -0
- package/dist/sync/v3/workspaceSyncV3FeedProfile.js +21 -0
- package/dist/sync/v3/workspaceSyncV3InitiativeLocalApply.d.ts +36 -0
- package/dist/sync/v3/workspaceSyncV3InitiativeLocalApply.js +163 -0
- package/dist/sync/v3/workspaceSyncV3InitiativeRepair.d.ts +33 -0
- package/dist/sync/v3/workspaceSyncV3InitiativeRepair.js +118 -0
- package/dist/sync/v3/workspaceSyncV3LegacyBoundaryAttestation.d.ts +10 -0
- package/dist/sync/v3/workspaceSyncV3LegacyBoundaryAttestation.js +45 -0
- package/dist/sync/v3/workspaceSyncV3LocalOutboxStatus.d.ts +27 -0
- package/dist/sync/v3/workspaceSyncV3LocalOutboxStatus.js +83 -0
- package/dist/sync/v3/workspaceSyncV3NestedCoverage.d.ts +10 -0
- package/dist/sync/v3/workspaceSyncV3NestedCoverage.js +40 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskApply.d.ts +83 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskApply.js +325 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskCodecs.d.ts +59 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskCodecs.js +302 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskProjection.d.ts +47 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskProjection.js +329 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskRepairSnapshot.d.ts +19 -0
- package/dist/sync/v3/workspaceSyncV3NestedTaskRepairSnapshot.js +41 -0
- package/dist/sync/v3/workspaceSyncV3NestedWireContract.d.ts +6 -0
- package/dist/sync/v3/workspaceSyncV3NestedWireContract.js +16 -0
- package/dist/sync/v3/workspaceSyncV3OperationProjection.d.ts +8 -0
- package/dist/sync/v3/workspaceSyncV3OperationProjection.js +52 -0
- package/dist/sync/v3/workspaceSyncV3OperationProtocol.d.ts +229 -0
- package/dist/sync/v3/workspaceSyncV3OperationProtocol.js +1 -0
- package/dist/sync/v3/workspaceSyncV3OperatorDiagnostics.d.ts +165 -0
- package/dist/sync/v3/workspaceSyncV3OperatorDiagnostics.js +414 -0
- package/dist/sync/v3/workspaceSyncV3OriginActor.d.ts +36 -0
- package/dist/sync/v3/workspaceSyncV3OriginActor.js +61 -0
- package/dist/sync/v3/workspaceSyncV3OriginAssertion.d.ts +46 -0
- package/dist/sync/v3/workspaceSyncV3OriginAssertion.js +121 -0
- package/dist/sync/v3/workspaceSyncV3OriginAssertionHeader.d.ts +1 -0
- package/dist/sync/v3/workspaceSyncV3OriginAssertionHeader.js +1 -0
- package/dist/sync/v3/workspaceSyncV3PlanningOrderCoverage.d.ts +10 -0
- package/dist/sync/v3/workspaceSyncV3PlanningOrderCoverage.js +34 -0
- package/dist/sync/v3/workspaceSyncV3PlanningOrderWireContract.d.ts +6 -0
- package/dist/sync/v3/workspaceSyncV3PlanningOrderWireContract.js +10 -0
- package/dist/sync/v3/workspaceSyncV3ProjectionState.d.ts +105 -0
- package/dist/sync/v3/workspaceSyncV3ProjectionState.js +467 -0
- package/dist/sync/v3/workspaceSyncV3TaskCommentAudit.d.ts +56 -0
- package/dist/sync/v3/workspaceSyncV3TaskCommentAudit.js +250 -0
- package/dist/sync/v3/workspaceSyncV3TaskRelationshipProjection.d.ts +30 -0
- package/dist/sync/v3/workspaceSyncV3TaskRelationshipProjection.js +142 -0
- package/dist/sync/v3/workspaceSyncV3TaskRootProjection.d.ts +40 -0
- package/dist/sync/v3/workspaceSyncV3TaskRootProjection.js +246 -0
- package/dist/sync/v3/workspaceSyncV3TaxonomyFeedProjection.d.ts +39 -0
- package/dist/sync/v3/workspaceSyncV3TaxonomyFeedProjection.js +185 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowCoverage.d.ts +10 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowCoverage.js +36 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowRuntimeApply.d.ts +17 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowRuntimeApply.js +143 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowRuntimeProjection.d.ts +9 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowRuntimeProjection.js +195 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowWireContract.d.ts +11 -0
- package/dist/sync/v3/workspaceSyncV3WorkflowWireContract.js +12 -0
- package/dist/sync/v3/workspaceSyncV3WorkstreamProjection.d.ts +4 -0
- package/dist/sync/v3/workspaceSyncV3WorkstreamProjection.js +98 -0
- package/dist/sync/v3/workspaceSyncV3WorkstreamTaskOrderProjection.d.ts +9 -0
- package/dist/sync/v3/workspaceSyncV3WorkstreamTaskOrderProjection.js +72 -0
- package/dist/sync/v3/workstreamActivityAuthoritativeRevision.d.ts +11 -0
- package/dist/sync/v3/workstreamActivityAuthoritativeRevision.js +23 -0
- package/dist/sync/v3/workstreamActivityIdentity.d.ts +8 -0
- package/dist/sync/v3/workstreamActivityIdentity.js +14 -0
- package/dist/sync/v3/workstreamCoexistence.d.ts +19 -0
- package/dist/sync/v3/workstreamCoexistence.js +64 -0
- package/dist/sync/v3/workstreamMutationPayload.d.ts +27 -0
- package/dist/sync/v3/workstreamMutationPayload.js +147 -0
- package/dist/sync/v3/workstreamMutationService.d.ts +72 -0
- package/dist/sync/v3/workstreamMutationService.js +256 -0
- package/dist/sync/v3/workstreamTaskOrderMutationService.d.ts +55 -0
- package/dist/sync/v3/workstreamTaskOrderMutationService.js +110 -0
- package/dist/sync/v3/workstreamTaskOrderSync.d.ts +14 -0
- package/dist/sync/v3/workstreamTaskOrderSync.js +25 -0
- package/dist/sync/workspaceContentManifest.d.ts +86 -0
- package/dist/sync/workspaceContentManifest.js +123 -0
- package/dist/sync/workspacePullFeed.d.ts +186 -2
- package/dist/sync/workspacePullFeed.js +513 -136
- package/dist/sync/workspacePushPreparedPlan.d.ts +21 -0
- package/dist/sync/workspacePushPreparedPlan.js +1 -0
- package/dist/sync/workspaceRepair.d.ts +17 -25
- package/dist/sync/workspaceRepair.js +348 -25
- package/dist/sync/workspaceSyncContentDecryption.d.ts +6 -0
- package/dist/sync/workspaceSyncContentDecryption.js +11 -0
- package/dist/sync/workspaceSyncModel.d.ts +216 -5
- package/dist/sync/workspaceSyncModel.js +371 -35
- package/dist/sync/workspaceSyncSnapshotProjection.d.ts +11 -0
- package/dist/sync/workspaceSyncSnapshotProjection.js +101 -0
- package/dist/sync/workspaceSyncState.d.ts +13 -0
- package/dist/sync/workspaceSyncState.js +36 -0
- package/dist/sync/workspaceSyncV2SnapshotBuilder.d.ts +106 -0
- package/dist/sync/workspaceSyncV2SnapshotBuilder.js +193 -0
- package/dist/types.d.ts +23 -15
- package/dist/types.js +1 -3
- package/dist/ui/ai-profile-clients/amazon-nova.svg +1 -0
- package/dist/ui/ai-profile-clients/antigravity.png +0 -0
- package/dist/ui/ai-profile-clients/chatgpt.svg +10 -0
- package/dist/ui/ai-profile-clients/claude-chat.svg +1 -0
- package/dist/ui/ai-profile-clients/claude-code.svg +15 -0
- package/dist/ui/ai-profile-clients/cline-app-icon-light.png +0 -0
- package/dist/ui/ai-profile-clients/cline.svg +16 -0
- package/dist/ui/ai-profile-clients/codex.svg +1 -0
- package/dist/ui/ai-profile-clients/copilot.png +0 -0
- package/dist/ui/ai-profile-clients/cursor.svg +1 -0
- package/dist/ui/ai-profile-clients/deepseek.svg +1 -0
- package/dist/ui/ai-profile-clients/gemini-cli-icon.png +0 -0
- package/dist/ui/ai-profile-clients/gemini.svg +10 -0
- package/dist/ui/ai-profile-clients/grok.svg +31 -0
- package/dist/ui/ai-profile-clients/kiro.svg +11 -0
- package/dist/ui/ai-profile-clients/llama.svg +1 -0
- package/dist/ui/ai-profile-clients/mistral.svg +1 -0
- package/dist/ui/ai-profile-clients/ms_copilot.jpeg +0 -0
- package/dist/ui/ai-profile-clients/openclaw.png +0 -0
- package/dist/ui/ai-profile-clients/perplexity.svg +1 -0
- package/dist/ui/ai-profile-clients/vscode.svg +41 -0
- package/dist/ui/ai-profile-clients/windsurf.svg +11 -0
- package/dist/ui/assets/AiIdentityRosterCard-DB1BLO_j.js +1 -0
- package/dist/ui/assets/AiProfilesModule-DPxQeF9N.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CBRZ8rca.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-DHNYaNg6.js +3 -0
- package/dist/ui/assets/AssetTraySortControl-B9k5caQW.css +1 -0
- package/dist/ui/assets/AssetTraySortControl-DphbyHVc.js +1 -0
- package/dist/ui/assets/ContextAttachmentManager-CMxSzHqc.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BC7sy5TG.js +261 -0
- package/dist/ui/assets/DocumentWorkspace-BIBGBfd7.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-BfJbZRdl.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-pJ2ZjZ-k.js +1 -0
- package/dist/ui/assets/PlanningModule-CeTqXMMz.css +1 -0
- package/dist/ui/assets/PlanningModule-t5GbvVR9.js +1 -0
- package/dist/ui/assets/PlansPage-BVH_NfgT.js +1 -0
- package/dist/ui/assets/PlansPage-C2nkK2Bp.css +1 -0
- package/dist/ui/assets/TaskContextUpload-Ds4LnDzV.css +1 -0
- package/dist/ui/assets/TaskContextUpload-bl4fOOUz.js +1 -0
- package/dist/ui/assets/TaskSettings-xVsxSa0g.js +12 -0
- package/dist/ui/assets/TaskforceAgentsModule-CkAxW19K.js +21 -0
- package/dist/ui/assets/TaskforceAgentsModule-D6IC0S7Q.css +1 -0
- package/dist/ui/assets/WorkflowManagerModule-BKqIGVnF.js +1 -0
- package/dist/ui/assets/WorkflowManagerModule-DF10XENz.css +1 -0
- package/dist/ui/assets/antigravity-DRKQga2U.png +0 -0
- package/dist/ui/assets/cursor-BM_3k4Y4.svg +1 -0
- package/dist/ui/assets/gemini-cli-icon-dBAVjCcn.png +0 -0
- package/dist/ui/assets/grok-BuodbWaG.svg +31 -0
- package/dist/ui/assets/index-BzJlVnNS.css +1 -0
- package/dist/ui/assets/index-Cx7vZJHL.js +7 -0
- package/dist/ui/assets/{vendor-dnd-DRzYolkg.js → vendor-dnd-CJ-AjP-Y.js} +1 -1
- package/dist/ui/assets/{vendor-icons-CLnehDTw.js → vendor-icons-B8ZNsH1g.js} +1 -1
- package/dist/ui/assets/{vendor-markdown-BUxTU7dS.js → vendor-markdown-_lhNCyq-.js} +9 -9
- package/dist/ui/assets/vendor-router-AqJMU8Lz.js +3 -0
- package/dist/ui/assets/vscode-C_7wk1WI.svg +41 -0
- package/dist/ui/branding/logos/logo_dark_bg.png +0 -0
- package/dist/ui/branding/logos/logo_favicon_dark.png +0 -0
- package/dist/ui/branding/logos/logo_favicon_dark.svg +56 -0
- package/dist/ui/branding/logos/logo_favicon_light.png +0 -0
- package/dist/ui/branding/logos/logo_light_bg.png +0 -0
- package/dist/ui/images/Taskforce_Van.webp +0 -0
- package/dist/ui/index.html +6 -6
- package/dist/update/uiUpdateService.d.ts +43 -0
- package/dist/update/uiUpdateService.js +149 -0
- package/dist/update/updateCli.d.ts +15 -0
- package/dist/update/updateCli.js +76 -0
- package/dist/update/updateExecutor.d.ts +40 -0
- package/dist/update/updateExecutor.js +474 -0
- package/dist/update/updateStatus.d.ts +83 -0
- package/dist/update/updateStatus.js +703 -0
- package/dist/utils/aiProfileAvatarPaths.d.ts +14 -0
- package/dist/utils/aiProfileAvatarPaths.js +52 -0
- package/dist/utils/aiProfileDefaultLogo.d.ts +7 -0
- package/dist/utils/aiProfileDefaultLogo.js +84 -0
- package/dist/utils/aiProfileEvents.d.ts +9 -1
- package/dist/utils/annotatedAttachments.d.ts +8 -0
- package/dist/utils/annotatedAttachments.js +17 -1
- package/dist/utils/assetTraySorting.d.ts +25 -0
- package/dist/utils/assetTraySorting.js +83 -0
- package/dist/utils/assignees.d.ts +5 -0
- package/dist/utils/assignees.js +14 -9
- package/dist/utils/authLogin.d.ts +10 -0
- package/dist/utils/authLogin.js +8 -0
- package/dist/utils/authSessionEvents.d.ts +2 -0
- package/dist/utils/authSessionEvents.js +6 -0
- package/dist/utils/avatarUpload.js +5 -5
- package/dist/utils/canonicalReferenceAttachments.d.ts +12 -0
- package/dist/utils/canonicalReferenceAttachments.js +42 -0
- package/dist/utils/cloudAssetDisplay.d.ts +2 -0
- package/dist/utils/cloudAssetDisplay.js +8 -0
- package/dist/utils/constants.d.ts +4 -11
- package/dist/utils/constants.js +5 -64
- package/dist/utils/contextAttachmentPreview.d.ts +11 -0
- package/dist/utils/contextAttachmentPreview.js +8 -0
- package/dist/utils/contextAttachmentUpload.d.ts +8 -0
- package/dist/utils/contextAttachmentUpload.js +144 -0
- package/dist/utils/documentNames.js +6 -4
- package/dist/utils/entityReferences.d.ts +14 -0
- package/dist/utils/entityReferences.js +21 -0
- package/dist/utils/httpSetCookie.d.ts +3 -0
- package/dist/utils/httpSetCookie.js +22 -0
- package/dist/utils/imageNoteStoragePaths.d.ts +3 -0
- package/dist/utils/imageNoteStoragePaths.js +19 -0
- package/dist/utils/imageReferences.d.ts +6 -0
- package/dist/utils/imageReferences.js +1 -0
- package/dist/utils/planningArchiveOrchestrator.d.ts +23 -0
- package/dist/utils/planningArchiveOrchestrator.js +37 -0
- package/dist/utils/planningArchivePresentation.d.ts +20 -0
- package/dist/utils/planningArchivePresentation.js +23 -0
- package/dist/utils/planningArchiveReadiness.d.ts +26 -0
- package/dist/utils/planningArchiveReadiness.js +30 -0
- package/dist/utils/planningFavorites.d.ts +7 -0
- package/dist/utils/planningFavorites.js +34 -0
- package/dist/utils/syncEventPresentation.js +53 -0
- package/dist/utils/syncStatusPresentation.d.ts +25 -0
- package/dist/utils/syncStatusPresentation.js +86 -5
- package/dist/utils/taskActivity.d.ts +9 -2
- package/dist/utils/taskActivity.js +61 -0
- package/dist/utils/taskDraftDefaults.d.ts +0 -8
- package/dist/utils/taskDraftDefaults.js +0 -4
- package/dist/utils/taskEvents.d.ts +11 -0
- package/dist/utils/taskEvents.js +6 -0
- package/dist/utils/taskHeaderQuickFilter.d.ts +14 -0
- package/dist/utils/taskHeaderQuickFilter.js +32 -0
- package/dist/utils/taskInvalidation.d.ts +23 -0
- package/dist/utils/taskInvalidation.js +67 -0
- package/dist/utils/taskNormalization.d.ts +2 -1
- package/dist/utils/taskNormalization.js +44 -18
- package/dist/utils/taskReferences.d.ts +16 -0
- package/dist/utils/taskReferences.js +63 -0
- package/dist/utils/taskforceAgentEvents.d.ts +8 -0
- package/dist/utils/taskforceAgentEvents.js +6 -0
- package/dist/utils/taskforceAgentSkillEvents.d.ts +7 -0
- package/dist/utils/taskforceAgentSkillEvents.js +8 -0
- package/dist/utils/uiNotice.d.ts +3 -0
- package/dist/utils/uiNotice.js +1 -1
- package/dist/utils/workspaceResourceEvents.d.ts +7 -0
- package/dist/utils/workspaceResourceEvents.js +6 -0
- package/dist/utils/workspaceSyncPresentation.d.ts +24 -0
- package/dist/utils/workspaceSyncPresentation.js +243 -6
- package/package.json +69 -21
- package/dist/components/features/AgentsModule.d.ts +0 -18
- package/dist/components/features/AgentsModule.js +0 -479
- package/dist/components/features/InitiativesModule.d.ts +0 -22
- package/dist/components/features/InitiativesModule.js +0 -33
- package/dist/components/features/WorkflowsModule.d.ts +0 -27
- package/dist/components/features/WorkflowsModule.js +0 -21
- package/dist/components/features/settings/WorkflowEditor.d.ts +0 -15
- package/dist/components/features/settings/WorkflowEditor.js +0 -245
- package/dist/components/views/panels/DocumentFilterToolbar.d.ts +0 -10
- package/dist/components/views/panels/DocumentFilterToolbar.js +0 -7
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +0 -61
- package/dist/components/views/standalone/modals/AccountHubModal.js +0 -108
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +0 -19
- package/dist/components/views/standalone/modals/EditProfileModal.js +0 -11
- package/dist/hooks/ui/useResourceExport.d.ts +0 -21
- package/dist/hooks/ui/useResourceExport.js +0 -87
- package/dist/hooks/ui/useWorkflowTemplates.d.ts +0 -37
- package/dist/hooks/ui/useWorkflowTemplates.js +0 -116
- package/dist/mcp/clientProfileDefaults.d.ts +0 -12
- package/dist/mcp/clientProfileDefaults.js +0 -41
- package/dist/resources/templates/WORKFLOW_V2_POLICY.md +0 -55
- package/dist/resources/templates/environments/aider.yaml +0 -14
- package/dist/resources/templates/environments/antigravity.yaml +0 -10
- package/dist/resources/templates/environments/claude.yaml +0 -10
- package/dist/resources/templates/environments/cline.yaml +0 -10
- package/dist/resources/templates/environments/codex.yaml +0 -13
- package/dist/resources/templates/environments/copilot.yaml +0 -10
- package/dist/resources/templates/environments/cursor.yaml +0 -9
- package/dist/resources/templates/environments/gemini.yaml +0 -9
- package/dist/resources/templates/environments/windsurf.yaml +0 -10
- package/dist/resources/templates/workflow-sources/README.md +0 -9
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +0 -530
- package/dist/resources/templates/workflow-sources/workflowEditorOverrides.json +0 -1
- package/dist/resources/templates/workflows/collaborate.yaml +0 -109
- package/dist/resources/templates/workflows/do.yaml +0 -87
- package/dist/resources/templates/workflows/evaluate.yaml +0 -116
- package/dist/resources/templates/workflows/execute.yaml +0 -156
- package/dist/resources/templates/workflows/plan.yaml +0 -118
- package/dist/resources/templates/workflows/review.yaml +0 -114
- package/dist/services/formatTransformers.d.ts +0 -29
- package/dist/services/formatTransformers.js +0 -56
- package/dist/services/templateEngine.d.ts +0 -46
- package/dist/services/templateEngine.js +0 -148
- package/dist/shared/workflowDefaults.d.ts +0 -9
- package/dist/shared/workflowDefaults.js +0 -11
- package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
- package/dist/ui/agent-logos/antigravity.jpeg +0 -0
- package/dist/ui/agent-logos/chatgpt.png +0 -0
- package/dist/ui/agent-logos/claude-code.jpeg +0 -0
- package/dist/ui/agent-logos/codex.jpeg +0 -0
- package/dist/ui/agent-logos/cursor.png +0 -0
- package/dist/ui/agent-logos/openclaw.jpeg +0 -0
- package/dist/ui/agent-logos/windsurf.png +0 -0
- package/dist/ui/assets/AgentsModule-C4LcqhcZ.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-B-Dq22d2.js +0 -3
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +0 -1
- package/dist/ui/assets/ContextAttachmentManager-DsXPTvi-.js +0 -3
- package/dist/ui/assets/DocumentWorkspace-CxA1wO3U.js +0 -252
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +0 -1
- package/dist/ui/assets/EntityActivityTimeline-B8nSe8l3.js +0 -1
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +0 -1
- package/dist/ui/assets/InitiativesModule-VY95v38E.js +0 -1
- package/dist/ui/assets/PlansPage-CQbKDv1a.js +0 -1
- package/dist/ui/assets/PlansPage-DeAzWYTb.css +0 -1
- package/dist/ui/assets/TaskContextUpload-DvDlNpHC.js +0 -1
- package/dist/ui/assets/TaskSettings-CUWbYYtP.js +0 -12
- package/dist/ui/assets/WorkflowsModule-CgM3Jako.js +0 -5
- package/dist/ui/assets/documentReferences-Do9YjrS3.js +0 -1
- package/dist/ui/assets/index--mh3pktj.js +0 -5
- package/dist/ui/assets/index-BawaaEXS.css +0 -1
- package/dist/ui/assets/vendor-router-BbWMxlnO.js +0 -3
- package/dist/ui/branding/logos/logo_black_01.png +0 -0
- package/dist/ui/branding/logos/logo_black_02.png +0 -0
- package/dist/ui/branding/logos/logo_white_01.png +0 -0
- package/dist/ui/branding/logos/logo_white_02.png +0 -0
- package/scripts/generate-workflow-templates.mjs +0 -134
- package/src/resources/templates/WORKFLOW_V2_POLICY.md +0 -55
- package/src/resources/templates/environments/aider.yaml +0 -14
- package/src/resources/templates/environments/antigravity.yaml +0 -10
- package/src/resources/templates/environments/claude.yaml +0 -10
- package/src/resources/templates/environments/cline.yaml +0 -10
- package/src/resources/templates/environments/codex.yaml +0 -13
- package/src/resources/templates/environments/copilot.yaml +0 -10
- package/src/resources/templates/environments/cursor.yaml +0 -9
- package/src/resources/templates/environments/gemini.yaml +0 -9
- package/src/resources/templates/environments/windsurf.yaml +0 -10
- package/src/resources/templates/workflow-sources/README.md +0 -9
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +0 -530
- package/src/resources/templates/workflow-sources/workflowEditorOverrides.json +0 -1
- package/src/resources/templates/workflows/collaborate.yaml +0 -109
- package/src/resources/templates/workflows/do.yaml +0 -87
- package/src/resources/templates/workflows/evaluate.yaml +0 -116
- package/src/resources/templates/workflows/execute.yaml +0 -156
- package/src/resources/templates/workflows/plan.yaml +0 -118
- package/src/resources/templates/workflows/review.yaml +0 -114
- /package/dist/ui/branding/logos/{logo_dark.svg → logo_dark_no_bg.svg} +0 -0
- /package/dist/ui/branding/logos/{logo_favicon.svg → logo_favicon_light.svg} +0 -0
- /package/dist/ui/branding/logos/{logo_light.svg → logo_light_no_bg.svg} +0 -0
|
@@ -0,0 +1,4635 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { normalizePlanningComments, } from '../planningCanonicalFields.js';
|
|
3
|
+
import { canonicalWorkspaceSyncJson } from './canonicalWorkspaceSyncJson.js';
|
|
4
|
+
import { buildTaskCreateActivityEventId } from './taskCreateActivityIdentity.js';
|
|
5
|
+
import { buildTaskCommentActivityEventId } from './taskCommentActivityIdentity.js';
|
|
6
|
+
import { buildTaskLifecycleActivityEventId, } from './taskLifecycleActivityIdentity.js';
|
|
7
|
+
import { buildTaskMetadataActivityEventId } from './taskMetadataActivityIdentity.js';
|
|
8
|
+
import { parseWorkspaceSyncV3OriginActor } from './workspaceSyncV3OriginActor.js';
|
|
9
|
+
import { WorkspaceSyncJournalReader } from './workspaceSyncJournalReader.js';
|
|
10
|
+
import { isLegacyEntityEventAdoptionIdentity, LEGACY_ENTITY_EVENT_ADOPTION_MAX_PAYLOAD_BYTES, } from './entityEventLegacyAdoptionContract.js';
|
|
11
|
+
import { validateWorkspaceSyncV3EntityEventPayload } from './workspaceSyncV3NestedTaskCodecs.js';
|
|
12
|
+
import { fingerprintWorkspaceSyncPayload } from './workspaceSyncPayloadFingerprint.js';
|
|
13
|
+
export { canonicalWorkspaceSyncJson } from './canonicalWorkspaceSyncJson.js';
|
|
14
|
+
export { fingerprintWorkspaceSyncPayload } from './workspaceSyncPayloadFingerprint.js';
|
|
15
|
+
export const WORKSPACE_SYNC_CONTRACT_VERSION = 3;
|
|
16
|
+
export const WORKSPACE_SYNC_INITIATIVE_PROJECTION_LOCK = 'projection:initiative/full-v1';
|
|
17
|
+
export const WORKSPACE_SYNC_COMBINED_PROJECTION = 'coverage/task-planning-metadata-v1';
|
|
18
|
+
export const WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK = 'workspace-sync-v3-combined-projection';
|
|
19
|
+
const MAX_OUTBOX_FUTURE_DEADLINE_MS = 48 * 60 * 60_000;
|
|
20
|
+
const TASK_AGGREGATE_SUCCESSOR_MUTATIONS = new Map([
|
|
21
|
+
['task-comments', new Set(['append', 'reconcile'])],
|
|
22
|
+
['task-checklist', new Set(['replace'])],
|
|
23
|
+
['task-attachment-links', new Set(['replace'])],
|
|
24
|
+
]);
|
|
25
|
+
const TASK_ROOT_SUCCESSOR_MUTATIONS = new Set([
|
|
26
|
+
'metadata-update', 'set-status', 'archive', 'unarchive', 'complete', 'reopen',
|
|
27
|
+
]);
|
|
28
|
+
const TASK_CREATE_RECOVERY_ERROR_CODES = new Set([
|
|
29
|
+
'TASK_CREATE_ID_CONFLICT',
|
|
30
|
+
'WORKSPACE_SYNC_REVISION_CONFLICT',
|
|
31
|
+
]);
|
|
32
|
+
const TASK_CREATE_FIELDS = new Set([
|
|
33
|
+
'title', 'description', 'priority', 'complexity', 'type', 'category', 'assignee',
|
|
34
|
+
'status', 'scheduledDate', 'dueDate', 'orderInDay', 'workstreamId', 'taxonomies',
|
|
35
|
+
]);
|
|
36
|
+
const TASK_ATTACHMENT_LINK_FIELDS = new Set([
|
|
37
|
+
'assetId', 'role', 'order', 'caption', 'displayName', 'originalFilename',
|
|
38
|
+
'referenceNumber', 'referenceLabel',
|
|
39
|
+
]);
|
|
40
|
+
const TASK_ATTACHMENT_FORBIDDEN_METADATA_VALUE = /^(?:file|data|blob):|^https?:\/\/|^(?:~(?:\/|$)|\/(?:Users|home|private|tmp|var)(?:\/|$))|^[A-Za-z]:[\\/]/i;
|
|
41
|
+
const TASK_LIFECYCLE_ACTIVITY_MUTATIONS = new Set([
|
|
42
|
+
'archive', 'unarchive', 'complete', 'reopen', 'set-status', 'restore',
|
|
43
|
+
]);
|
|
44
|
+
function compareCodeUnits(left, right) {
|
|
45
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
function normalizeWorkspaceSyncLockTargets(targets) {
|
|
48
|
+
return Array.from(new Map(targets.map((target) => {
|
|
49
|
+
const domain = requireText(target.domain, 'Domain');
|
|
50
|
+
const entityId = requireText(target.entityId, 'Entity id');
|
|
51
|
+
return [`${domain}\u0000${entityId}`, { domain, entityId }];
|
|
52
|
+
})).values()).sort((left, right) => (compareCodeUnits(left.domain, right.domain) || compareCodeUnits(left.entityId, right.entityId)));
|
|
53
|
+
}
|
|
54
|
+
function requiresInitiativeProjectionLock(targets) {
|
|
55
|
+
return targets.some((target) => (target.domain === 'initiative'
|
|
56
|
+
|| target.domain === 'initiative-assets'
|
|
57
|
+
|| target.domain === 'initiative-owners'));
|
|
58
|
+
}
|
|
59
|
+
function requiresCombinedProjectionLock(targets) {
|
|
60
|
+
return targets.some((target) => (target.domain === 'task'
|
|
61
|
+
|| target.domain === 'task-relationship'
|
|
62
|
+
|| target.domain === 'taxonomy'
|
|
63
|
+
|| target.domain === 'initiative'
|
|
64
|
+
|| target.domain === 'workstream'));
|
|
65
|
+
}
|
|
66
|
+
export class WorkspaceSyncIdempotencyConflictError extends Error {
|
|
67
|
+
code = 'WORKSPACE_SYNC_IDEMPOTENCY_CONFLICT';
|
|
68
|
+
statusCode = 409;
|
|
69
|
+
constructor() {
|
|
70
|
+
super('The operation id is already bound to a different request.');
|
|
71
|
+
this.name = 'WorkspaceSyncIdempotencyConflictError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export class WorkspaceSyncPlanningOwnershipChangedError extends Error {
|
|
75
|
+
code = 'WORKSPACE_SYNC_PLANNING_OWNERSHIP_CHANGED';
|
|
76
|
+
statusCode = 409;
|
|
77
|
+
constructor() {
|
|
78
|
+
super('Durable planning ownership changed before the local operation could be captured.');
|
|
79
|
+
this.name = 'WorkspaceSyncPlanningOwnershipChangedError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export class WorkspaceSyncLeaseLostError extends Error {
|
|
83
|
+
code = 'WORKSPACE_SYNC_LEASE_LOST';
|
|
84
|
+
constructor() {
|
|
85
|
+
super('The workspace sync operation lease is no longer owned by this caller.');
|
|
86
|
+
this.name = 'WorkspaceSyncLeaseLostError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class WorkspaceSyncRevisionConflictError extends Error {
|
|
90
|
+
code = 'WORKSPACE_SYNC_REVISION_CONFLICT';
|
|
91
|
+
statusCode = 409;
|
|
92
|
+
details;
|
|
93
|
+
constructor(details) {
|
|
94
|
+
super('The workspace entity revision no longer matches the operation base revision.');
|
|
95
|
+
this.name = 'WorkspaceSyncRevisionConflictError';
|
|
96
|
+
this.details = details;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export class WorkspaceSyncEntityBusyError extends Error {
|
|
100
|
+
code = 'WORKSPACE_SYNC_ENTITY_BUSY';
|
|
101
|
+
statusCode = 409;
|
|
102
|
+
details;
|
|
103
|
+
retryable;
|
|
104
|
+
retryAfterAt;
|
|
105
|
+
retryHint;
|
|
106
|
+
constructor(input) {
|
|
107
|
+
super('The workspace entity already has an unaccepted local operation.');
|
|
108
|
+
this.name = 'WorkspaceSyncEntityBusyError';
|
|
109
|
+
this.details = input?.details;
|
|
110
|
+
this.retryable = input?.retryable !== false;
|
|
111
|
+
this.retryAfterAt = input?.retryAfterAt;
|
|
112
|
+
this.retryHint = input?.retryHint || (this.retryable
|
|
113
|
+
? this.retryAfterAt
|
|
114
|
+
? `Wait until at least ${this.retryAfterAt} for Sync Manager to retry the blocking operation, then retry the same tool call. Refresh alone will not clear it.`
|
|
115
|
+
: 'Let Sync Manager dispatch the blocking operation, then retry the same tool call. Refresh alone will not clear it.'
|
|
116
|
+
: 'The blocking sync operation needs recovery. Do not keep retrying this mutation; open Sync Manager and resolve the reported blocked operation first.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function resolveWorkspaceSyncActiveOperationState(input) {
|
|
120
|
+
const status = String(input.status || '');
|
|
121
|
+
if (status === 'blocked')
|
|
122
|
+
return 'blocked';
|
|
123
|
+
const nowMs = Date.parse(input.now);
|
|
124
|
+
const nextAttemptMs = input.nextAttemptAt == null
|
|
125
|
+
? Number.NaN
|
|
126
|
+
: Date.parse(String(input.nextAttemptAt));
|
|
127
|
+
const leaseExpiresMs = input.leaseExpiresAt == null
|
|
128
|
+
? Number.NaN
|
|
129
|
+
: Date.parse(String(input.leaseExpiresAt));
|
|
130
|
+
if (status === 'in_flight') {
|
|
131
|
+
return Number.isFinite(leaseExpiresMs) && leaseExpiresMs <= nowMs
|
|
132
|
+
? 'stale'
|
|
133
|
+
: 'in-flight';
|
|
134
|
+
}
|
|
135
|
+
return Number.isFinite(nextAttemptMs) && nextAttemptMs > nowMs
|
|
136
|
+
? 'retry-scheduled'
|
|
137
|
+
: 'pending';
|
|
138
|
+
}
|
|
139
|
+
function workspaceSyncEntityBusyError(input) {
|
|
140
|
+
const nowMs = Date.parse(input.now);
|
|
141
|
+
const allBlockingOperations = input.active.map((row) => {
|
|
142
|
+
const status = String(row.status || '');
|
|
143
|
+
const nextAttemptAt = row.next_attempt_at == null ? null : String(row.next_attempt_at);
|
|
144
|
+
const leaseExpiresAt = row.lease_expires_at == null ? null : String(row.lease_expires_at);
|
|
145
|
+
const state = resolveWorkspaceSyncActiveOperationState({
|
|
146
|
+
status,
|
|
147
|
+
nextAttemptAt,
|
|
148
|
+
leaseExpiresAt,
|
|
149
|
+
now: input.now,
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
operationId: String(row.operation_id || ''),
|
|
153
|
+
domain: String(row.domain || ''),
|
|
154
|
+
entityId: String(row.entity_id || ''),
|
|
155
|
+
mutationKind: String(row.mutation_kind || ''),
|
|
156
|
+
state,
|
|
157
|
+
attemptCount: Number(row.attempt_count || 0),
|
|
158
|
+
createdAt: String(row.created_at || ''),
|
|
159
|
+
updatedAt: String(row.updated_at || ''),
|
|
160
|
+
...(nextAttemptAt ? { nextAttemptAt } : {}),
|
|
161
|
+
...(leaseExpiresAt ? { leaseExpiresAt } : {}),
|
|
162
|
+
...(row.last_error_code ? { errorCode: String(row.last_error_code) } : {}),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
const blockingOperations = allBlockingOperations.slice(0, 5);
|
|
166
|
+
const retryable = allBlockingOperations.every((operation) => operation.state !== 'blocked');
|
|
167
|
+
const retryAfterAt = retryable
|
|
168
|
+
? allBlockingOperations
|
|
169
|
+
.flatMap((operation) => [operation.nextAttemptAt, operation.leaseExpiresAt])
|
|
170
|
+
.filter((value) => typeof value === 'string' && Date.parse(value) > nowMs)
|
|
171
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left))[0]
|
|
172
|
+
: undefined;
|
|
173
|
+
const retryHint = retryable && !retryAfterAt
|
|
174
|
+
&& allBlockingOperations.some((operation) => operation.state === 'stale')
|
|
175
|
+
? 'The blocking operation has an expired lease and is reclaimable. Run or check Sync Manager, then retry the same tool call after it reclaims the operation. Refresh alone will not clear it.'
|
|
176
|
+
: undefined;
|
|
177
|
+
return new WorkspaceSyncEntityBusyError({
|
|
178
|
+
retryable,
|
|
179
|
+
retryAfterAt,
|
|
180
|
+
retryHint,
|
|
181
|
+
details: {
|
|
182
|
+
target: input.target,
|
|
183
|
+
blockingOperations,
|
|
184
|
+
blockingOperationCount: input.active.length,
|
|
185
|
+
diagnosticsTruncated: input.active.length > blockingOperations.length,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function requireText(value, label) {
|
|
190
|
+
const normalized = String(value || '').trim();
|
|
191
|
+
if (!normalized)
|
|
192
|
+
throw new Error(`${label} is required.`);
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
function requireOutboxDispatchClass(value) {
|
|
196
|
+
const normalized = requireText(value, 'Outbox dispatch class');
|
|
197
|
+
if (normalized !== 'ordinary' && normalized !== 'legacy-adoption') {
|
|
198
|
+
throw new Error('Outbox dispatch class is invalid.');
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
201
|
+
}
|
|
202
|
+
function requireIsoTimestamp(value, label) {
|
|
203
|
+
const normalized = requireText(value, label);
|
|
204
|
+
const epochMs = Date.parse(normalized);
|
|
205
|
+
if (!Number.isFinite(epochMs))
|
|
206
|
+
throw new Error(`${label} must be an ISO timestamp.`);
|
|
207
|
+
return new Date(epochMs).toISOString();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Authoritative feed apply may replace a provisional entity event only after
|
|
211
|
+
* durable recovery has explicitly superseded the operation that created it.
|
|
212
|
+
* Persisted targets or a recognized deterministic task-root identity provide
|
|
213
|
+
* the provenance proof; arbitrary same-ID conflicts continue to fail closed.
|
|
214
|
+
*/
|
|
215
|
+
export function hasCompletedRecoveryForSupersededEntityEvent(db, input) {
|
|
216
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
217
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
218
|
+
const entityType = requireText(input.entityType, 'Entity type');
|
|
219
|
+
const entityId = requireText(input.entityId, 'Entity id');
|
|
220
|
+
const eventId = requireText(input.eventId, 'Entity event id');
|
|
221
|
+
const explicitTarget = db.prepare(`
|
|
222
|
+
SELECT 1 AS proven
|
|
223
|
+
FROM workspace_sync_outbox AS source
|
|
224
|
+
INNER JOIN workspace_sync_outbox_recoveries AS recovery
|
|
225
|
+
ON recovery.tenant_id = source.tenant_id
|
|
226
|
+
AND recovery.workspace_id = source.workspace_id
|
|
227
|
+
AND recovery.recovery_batch_id = source.recovery_batch_id
|
|
228
|
+
INNER JOIN workspace_sync_outbox_targets AS event_target
|
|
229
|
+
ON event_target.tenant_id = source.tenant_id
|
|
230
|
+
AND event_target.workspace_id = source.workspace_id
|
|
231
|
+
AND event_target.client_id = source.client_id
|
|
232
|
+
AND event_target.operation_id = source.operation_id
|
|
233
|
+
AND event_target.domain = 'entity-event'
|
|
234
|
+
AND event_target.entity_id = ?
|
|
235
|
+
INNER JOIN workspace_sync_outbox_targets AS parent_target
|
|
236
|
+
ON parent_target.tenant_id = source.tenant_id
|
|
237
|
+
AND parent_target.workspace_id = source.workspace_id
|
|
238
|
+
AND parent_target.client_id = source.client_id
|
|
239
|
+
AND parent_target.operation_id = source.operation_id
|
|
240
|
+
AND parent_target.domain = ?
|
|
241
|
+
AND parent_target.entity_id = ?
|
|
242
|
+
WHERE source.tenant_id = ?
|
|
243
|
+
AND source.workspace_id = ?
|
|
244
|
+
AND source.status = 'superseded'
|
|
245
|
+
AND recovery.state = 'completed'
|
|
246
|
+
AND recovery.completed_at IS NOT NULL
|
|
247
|
+
LIMIT 1
|
|
248
|
+
`).get(eventId, entityType, entityId, tenantId, workspaceId);
|
|
249
|
+
if (Number(explicitTarget?.proven || 0) === 1)
|
|
250
|
+
return true;
|
|
251
|
+
if (entityType !== 'task')
|
|
252
|
+
return false;
|
|
253
|
+
const sources = db.prepare(`
|
|
254
|
+
SELECT source.client_id, source.operation_id, source.mutation_kind
|
|
255
|
+
FROM workspace_sync_outbox AS source
|
|
256
|
+
INNER JOIN workspace_sync_outbox_recoveries AS recovery
|
|
257
|
+
ON recovery.tenant_id = source.tenant_id
|
|
258
|
+
AND recovery.workspace_id = source.workspace_id
|
|
259
|
+
AND recovery.recovery_batch_id = source.recovery_batch_id
|
|
260
|
+
WHERE source.tenant_id = ?
|
|
261
|
+
AND source.workspace_id = ?
|
|
262
|
+
AND source.domain = 'task'
|
|
263
|
+
AND source.entity_id = ?
|
|
264
|
+
AND source.status = 'superseded'
|
|
265
|
+
AND recovery.state = 'completed'
|
|
266
|
+
AND recovery.completed_at IS NOT NULL
|
|
267
|
+
`).all(tenantId, workspaceId, entityId);
|
|
268
|
+
return sources.some((source) => {
|
|
269
|
+
const clientId = String(source.client_id || '').trim();
|
|
270
|
+
const operationId = String(source.operation_id || '').trim();
|
|
271
|
+
const mutationKind = String(source.mutation_kind || '').trim();
|
|
272
|
+
if (!clientId || !operationId)
|
|
273
|
+
return false;
|
|
274
|
+
if (mutationKind === 'create') {
|
|
275
|
+
return buildTaskCreateActivityEventId({
|
|
276
|
+
workspaceId, clientId, operationId, taskId: entityId,
|
|
277
|
+
}) === eventId;
|
|
278
|
+
}
|
|
279
|
+
if (mutationKind === 'metadata-update') {
|
|
280
|
+
return buildTaskMetadataActivityEventId({
|
|
281
|
+
workspaceId, clientId, operationId, taskId: entityId,
|
|
282
|
+
}) === eventId;
|
|
283
|
+
}
|
|
284
|
+
if (TASK_LIFECYCLE_ACTIVITY_MUTATIONS.has(mutationKind)) {
|
|
285
|
+
return buildTaskLifecycleActivityEventId({
|
|
286
|
+
workspaceId,
|
|
287
|
+
clientId,
|
|
288
|
+
operationId,
|
|
289
|
+
taskId: entityId,
|
|
290
|
+
mutationKind: mutationKind,
|
|
291
|
+
}) === eventId;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function buildRecoveryReplayOperationId(recoveryBatchId, baselineMarker, clientId, operationId) {
|
|
297
|
+
const digest = createHash('sha256')
|
|
298
|
+
.update(`${recoveryBatchId}\0${baselineMarker}\0${clientId}\0${operationId}`, 'utf8')
|
|
299
|
+
.digest('hex');
|
|
300
|
+
return `recovery-${digest}`;
|
|
301
|
+
}
|
|
302
|
+
function buildEntityEventAdoptionRecoveryReplayOperationId(recoveryBatchId, baselineMarker, clientId, operationId) {
|
|
303
|
+
const digest = createHash('sha256')
|
|
304
|
+
.update(`${recoveryBatchId}\0${baselineMarker}\0${clientId}\0${operationId}`, 'utf8')
|
|
305
|
+
.digest('hex');
|
|
306
|
+
return `legacy-entity-event-adoption-${digest}`;
|
|
307
|
+
}
|
|
308
|
+
function normalizeSequence(value) {
|
|
309
|
+
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
|
|
310
|
+
throw new Error('Workspace sync sequence exceeded the JavaScript safe integer range.');
|
|
311
|
+
}
|
|
312
|
+
const normalized = String(value ?? '').trim();
|
|
313
|
+
if (!/^\d+$/.test(normalized))
|
|
314
|
+
throw new Error('Workspace sync sequence is invalid.');
|
|
315
|
+
return BigInt(normalized).toString(10);
|
|
316
|
+
}
|
|
317
|
+
function normalizeRevisionKind(value) {
|
|
318
|
+
if (value === 'metadata' || value === 'lifecycle' || value === 'both')
|
|
319
|
+
return value;
|
|
320
|
+
throw new Error('Revision kind must be metadata, lifecycle, or both.');
|
|
321
|
+
}
|
|
322
|
+
function operationParams(identity) {
|
|
323
|
+
return [
|
|
324
|
+
requireText(identity.tenantId, 'Tenant id'),
|
|
325
|
+
requireText(identity.workspaceId, 'Workspace id'),
|
|
326
|
+
requireText(identity.clientId, 'Client id'),
|
|
327
|
+
requireText(identity.operationId, 'Operation id'),
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
export class WorkspaceSyncDurabilityStore {
|
|
331
|
+
db;
|
|
332
|
+
journalReader;
|
|
333
|
+
constructor(db) {
|
|
334
|
+
this.db = db;
|
|
335
|
+
this.journalReader = new WorkspaceSyncJournalReader(db);
|
|
336
|
+
}
|
|
337
|
+
readEntityRevisionCoordinates(input) {
|
|
338
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
339
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
340
|
+
const domain = requireText(input.domain, 'Domain');
|
|
341
|
+
const entityId = requireText(input.entityId, 'Entity id');
|
|
342
|
+
return this.journalReader.readEntityRevisionCoordinates({ tenantId, workspaceId, domain, entityId });
|
|
343
|
+
}
|
|
344
|
+
readInitiativeProjectionOwnership(input) {
|
|
345
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
346
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
347
|
+
const rows = this.db.prepare(`
|
|
348
|
+
SELECT projection_id, owner_mode, CAST(epoch AS TEXT) AS epoch
|
|
349
|
+
FROM workspace_sync_projection_state
|
|
350
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
351
|
+
AND projection_id IN (?, 'initiative/full-v1')
|
|
352
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION);
|
|
353
|
+
let combined = null;
|
|
354
|
+
let initiative = null;
|
|
355
|
+
for (const row of rows) {
|
|
356
|
+
if ((row.owner_mode !== 'v2' && row.owner_mode !== 'v3')
|
|
357
|
+
|| !/^\d+$/.test(String(row.epoch ?? ''))) {
|
|
358
|
+
throw new Error('Workspace sync initiative projection ownership is invalid.');
|
|
359
|
+
}
|
|
360
|
+
const ownership = {
|
|
361
|
+
owner: row.owner_mode,
|
|
362
|
+
epoch: BigInt(String(row.epoch)).toString(10),
|
|
363
|
+
};
|
|
364
|
+
if (row.projection_id === WORKSPACE_SYNC_COMBINED_PROJECTION)
|
|
365
|
+
combined = ownership;
|
|
366
|
+
else if (row.projection_id === 'initiative/full-v1')
|
|
367
|
+
initiative = ownership;
|
|
368
|
+
else
|
|
369
|
+
throw new Error('Workspace sync initiative projection ownership is invalid.');
|
|
370
|
+
}
|
|
371
|
+
if (combined?.owner === 'v3')
|
|
372
|
+
return combined;
|
|
373
|
+
if (initiative)
|
|
374
|
+
return initiative;
|
|
375
|
+
if (combined) {
|
|
376
|
+
if (combined.owner !== 'v2') {
|
|
377
|
+
throw new Error('Workspace sync combined projection ownership is invalid.');
|
|
378
|
+
}
|
|
379
|
+
return { owner: 'v2', epoch: '0' };
|
|
380
|
+
}
|
|
381
|
+
return { owner: 'v2', epoch: '0' };
|
|
382
|
+
}
|
|
383
|
+
readCombinedProjectionOwnership(input) {
|
|
384
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
385
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
386
|
+
const row = this.db.prepare(`
|
|
387
|
+
SELECT owner_mode, CAST(epoch AS TEXT) AS epoch
|
|
388
|
+
FROM workspace_sync_projection_state
|
|
389
|
+
WHERE tenant_id = ? AND workspace_id = ? AND projection_id = ?
|
|
390
|
+
`).get(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION);
|
|
391
|
+
if (!row)
|
|
392
|
+
return { owner: 'v2', epoch: '0' };
|
|
393
|
+
if ((row.owner_mode !== 'v2' && row.owner_mode !== 'v3')
|
|
394
|
+
|| !/^\d+$/.test(String(row.epoch ?? ''))) {
|
|
395
|
+
throw new Error('Workspace sync combined projection ownership is invalid.');
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
owner: row.owner_mode,
|
|
399
|
+
epoch: BigInt(String(row.epoch)).toString(10),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
runCombinedProjectionTransaction(input) {
|
|
403
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
404
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
405
|
+
const now = requireIsoTimestamp(input.now, 'Combined projection transaction time');
|
|
406
|
+
return this.db.transaction(() => {
|
|
407
|
+
this.db.prepare(`
|
|
408
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
409
|
+
VALUES (?, ?, ?, ?)
|
|
410
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
411
|
+
`).run(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
412
|
+
return input.apply(this.readCombinedProjectionOwnership({ tenantId, workspaceId }));
|
|
413
|
+
})();
|
|
414
|
+
}
|
|
415
|
+
readWorkspaceDomainOwnership(input) {
|
|
416
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
417
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
418
|
+
const domain = requireText(input.domain, 'Domain');
|
|
419
|
+
const entityId = requireText(input.entityId, 'Entity id');
|
|
420
|
+
const revision = this.db.prepare(`
|
|
421
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
422
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
423
|
+
CAST(last_sequence AS TEXT) AS last_sequence,
|
|
424
|
+
canonical_fingerprint, coexistence_owned
|
|
425
|
+
FROM workspace_sync_entity_revisions
|
|
426
|
+
WHERE tenant_id = ? AND workspace_id = ? AND contract_version = ?
|
|
427
|
+
AND domain = ? AND entity_id = ?
|
|
428
|
+
`).get(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, domain, entityId);
|
|
429
|
+
const active = this.db.prepare(`
|
|
430
|
+
SELECT 1 AS active
|
|
431
|
+
FROM workspace_sync_outbox AS outbox
|
|
432
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
433
|
+
AND outbox.contract_version = ?
|
|
434
|
+
AND outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
435
|
+
AND (
|
|
436
|
+
(outbox.domain = ? AND outbox.entity_id = ?)
|
|
437
|
+
OR EXISTS (
|
|
438
|
+
SELECT 1 FROM workspace_sync_outbox_targets AS target
|
|
439
|
+
WHERE target.tenant_id = outbox.tenant_id
|
|
440
|
+
AND target.workspace_id = outbox.workspace_id
|
|
441
|
+
AND target.client_id = outbox.client_id
|
|
442
|
+
AND target.operation_id = outbox.operation_id
|
|
443
|
+
AND target.domain = ? AND target.entity_id = ?
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
LIMIT 1
|
|
447
|
+
`).get(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, domain, entityId, domain, entityId);
|
|
448
|
+
const entityRevision = normalizeSequence(revision?.entity_revision ?? 0);
|
|
449
|
+
return {
|
|
450
|
+
domain,
|
|
451
|
+
entityId,
|
|
452
|
+
activeOperation: Number(active?.active || 0) === 1,
|
|
453
|
+
owned: BigInt(entityRevision) > 0n && Number(revision?.coexistence_owned) === 1,
|
|
454
|
+
entityRevision,
|
|
455
|
+
lifecycleRevision: normalizeSequence(revision?.lifecycle_revision ?? 0),
|
|
456
|
+
lastSequence: revision?.last_sequence === undefined ? null : normalizeSequence(revision.last_sequence),
|
|
457
|
+
canonicalFingerprint: revision?.canonical_fingerprint
|
|
458
|
+
? requireText(revision.canonical_fingerprint, 'Canonical fingerprint')
|
|
459
|
+
: null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
listWorkspaceDomainOwnership(input) {
|
|
463
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
464
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
465
|
+
const domain = requireText(input.domain, 'Domain');
|
|
466
|
+
const entityIds = Array.from(new Set(input.entityIds
|
|
467
|
+
.map((entityId) => requireText(entityId, 'Entity id'))))
|
|
468
|
+
.sort();
|
|
469
|
+
if (entityIds.length === 0)
|
|
470
|
+
return [];
|
|
471
|
+
if (entityIds.length > 100)
|
|
472
|
+
throw new Error('Domain ownership lookup supports at most 100 entity ids.');
|
|
473
|
+
const values = entityIds.map(() => '(?)').join(', ');
|
|
474
|
+
const rows = this.db.prepare(`
|
|
475
|
+
WITH requested(entity_id) AS (VALUES ${values})
|
|
476
|
+
SELECT requested.entity_id,
|
|
477
|
+
CAST(revision.entity_revision AS TEXT) AS entity_revision,
|
|
478
|
+
CAST(revision.lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
479
|
+
CAST(revision.last_sequence AS TEXT) AS last_sequence,
|
|
480
|
+
revision.canonical_fingerprint,
|
|
481
|
+
revision.coexistence_owned,
|
|
482
|
+
CASE WHEN EXISTS (
|
|
483
|
+
SELECT 1
|
|
484
|
+
FROM workspace_sync_outbox AS outbox
|
|
485
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
486
|
+
AND outbox.contract_version = ?
|
|
487
|
+
AND outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
488
|
+
AND (
|
|
489
|
+
(outbox.domain = ? AND outbox.entity_id = requested.entity_id)
|
|
490
|
+
OR EXISTS (
|
|
491
|
+
SELECT 1 FROM workspace_sync_outbox_targets AS target
|
|
492
|
+
WHERE target.tenant_id = outbox.tenant_id
|
|
493
|
+
AND target.workspace_id = outbox.workspace_id
|
|
494
|
+
AND target.client_id = outbox.client_id
|
|
495
|
+
AND target.operation_id = outbox.operation_id
|
|
496
|
+
AND target.domain = ? AND target.entity_id = requested.entity_id
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
) THEN 1 ELSE 0 END AS active_operation
|
|
500
|
+
FROM requested
|
|
501
|
+
LEFT JOIN workspace_sync_entity_revisions AS revision
|
|
502
|
+
ON revision.tenant_id = ? AND revision.workspace_id = ?
|
|
503
|
+
AND revision.contract_version = ? AND revision.domain = ?
|
|
504
|
+
AND revision.entity_id = requested.entity_id
|
|
505
|
+
ORDER BY requested.entity_id
|
|
506
|
+
`).all(...entityIds, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, domain, domain, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, domain);
|
|
507
|
+
return rows.map((row) => {
|
|
508
|
+
const entityRevision = normalizeSequence(row.entity_revision ?? 0);
|
|
509
|
+
return {
|
|
510
|
+
domain,
|
|
511
|
+
entityId: requireText(row.entity_id, 'Entity id'),
|
|
512
|
+
activeOperation: Number(row.active_operation || 0) === 1,
|
|
513
|
+
owned: BigInt(entityRevision) > 0n && Number(row.coexistence_owned) === 1,
|
|
514
|
+
entityRevision,
|
|
515
|
+
lifecycleRevision: normalizeSequence(row.lifecycle_revision ?? 0),
|
|
516
|
+
lastSequence: row.last_sequence === undefined || row.last_sequence === null
|
|
517
|
+
? null
|
|
518
|
+
: normalizeSequence(row.last_sequence),
|
|
519
|
+
canonicalFingerprint: row.canonical_fingerprint
|
|
520
|
+
? requireText(row.canonical_fingerprint, 'Canonical fingerprint')
|
|
521
|
+
: null,
|
|
522
|
+
};
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
listOwnedWorkspaceDomainRevisionsAfter(input) {
|
|
526
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
527
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
528
|
+
const domains = Array.from(new Set(input.domains.map((domain) => requireText(domain, 'Domain')))).sort();
|
|
529
|
+
const sequence = normalizeSequence(input.sequence);
|
|
530
|
+
if (domains.length === 0)
|
|
531
|
+
return [];
|
|
532
|
+
const placeholders = domains.map(() => '?').join(', ');
|
|
533
|
+
const rows = this.db.prepare(`
|
|
534
|
+
SELECT domain, entity_id, CAST(last_sequence AS TEXT) AS last_sequence
|
|
535
|
+
FROM workspace_sync_entity_revisions
|
|
536
|
+
WHERE tenant_id = ? AND workspace_id = ? AND contract_version = ?
|
|
537
|
+
AND domain IN (${placeholders})
|
|
538
|
+
AND coexistence_owned = 1
|
|
539
|
+
AND entity_revision > 0
|
|
540
|
+
AND last_sequence IS NOT NULL
|
|
541
|
+
AND last_sequence > ?
|
|
542
|
+
ORDER BY domain ASC, entity_id ASC
|
|
543
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, ...domains, sequence);
|
|
544
|
+
return rows.map((row) => ({
|
|
545
|
+
domain: requireText(row.domain, 'Domain'),
|
|
546
|
+
entityId: requireText(row.entity_id, 'Entity id'),
|
|
547
|
+
lastSequence: normalizeSequence(row.last_sequence),
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
applyReplicatedEntityRevision(input) {
|
|
551
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
552
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
553
|
+
const domain = requireText(input.domain, 'Domain');
|
|
554
|
+
const entityId = requireText(input.entityId, 'Entity id');
|
|
555
|
+
const sequence = normalizeSequence(input.sequence);
|
|
556
|
+
const entityRevision = normalizeSequence(input.entityRevision);
|
|
557
|
+
const lifecycleRevision = normalizeSequence(input.lifecycleRevision);
|
|
558
|
+
const canonicalFingerprint = requireText(input.canonicalFingerprint, 'Canonical fingerprint');
|
|
559
|
+
const now = requireIsoTimestamp(input.now, 'Replicated revision timestamp');
|
|
560
|
+
if (BigInt(sequence) <= 0n || BigInt(entityRevision) <= 0n || !/^[a-f0-9]{64}$/.test(canonicalFingerprint)) {
|
|
561
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
562
|
+
domain,
|
|
563
|
+
entityId,
|
|
564
|
+
reason: 'invalid-replicated-revision',
|
|
565
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return this.db.transaction(() => {
|
|
569
|
+
const current = this.readWorkspaceDomainOwnership({ tenantId, workspaceId, domain, entityId });
|
|
570
|
+
const applyAuthoritativeRevision = () => {
|
|
571
|
+
const result = input.applyMutation();
|
|
572
|
+
if (input.shouldRecordRevision?.(result) === false) {
|
|
573
|
+
return { state: 'deferred', result };
|
|
574
|
+
}
|
|
575
|
+
this.db.prepare(`
|
|
576
|
+
INSERT INTO workspace_sync_entity_revisions (
|
|
577
|
+
tenant_id, workspace_id, domain, entity_id, contract_version,
|
|
578
|
+
entity_revision, lifecycle_revision, coexistence_owned, metadata_owned, reopen_assignee_owned,
|
|
579
|
+
last_sequence, canonical_fingerprint, updated_at
|
|
580
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
|
581
|
+
ON CONFLICT (tenant_id, workspace_id, domain, entity_id) DO UPDATE SET
|
|
582
|
+
contract_version = excluded.contract_version,
|
|
583
|
+
entity_revision = excluded.entity_revision,
|
|
584
|
+
lifecycle_revision = excluded.lifecycle_revision,
|
|
585
|
+
coexistence_owned = 1,
|
|
586
|
+
metadata_owned = CASE
|
|
587
|
+
WHEN workspace_sync_entity_revisions.metadata_owned = 1 OR excluded.metadata_owned = 1 THEN 1
|
|
588
|
+
ELSE 0
|
|
589
|
+
END,
|
|
590
|
+
reopen_assignee_owned = CASE
|
|
591
|
+
WHEN workspace_sync_entity_revisions.reopen_assignee_owned = 1
|
|
592
|
+
OR excluded.reopen_assignee_owned = 1 THEN 1
|
|
593
|
+
ELSE 0
|
|
594
|
+
END,
|
|
595
|
+
last_sequence = excluded.last_sequence,
|
|
596
|
+
canonical_fingerprint = excluded.canonical_fingerprint,
|
|
597
|
+
updated_at = excluded.updated_at
|
|
598
|
+
`).run(tenantId, workspaceId, domain, entityId, WORKSPACE_SYNC_CONTRACT_VERSION, entityRevision, lifecycleRevision, input.metadataOwned ? 1 : 0, input.reopenAssigneeOwned ? 1 : 0, sequence, canonicalFingerprint, now);
|
|
599
|
+
return { state: 'applied', result };
|
|
600
|
+
};
|
|
601
|
+
const incomingEntity = BigInt(entityRevision);
|
|
602
|
+
const currentEntity = BigInt(current.entityRevision);
|
|
603
|
+
const incomingLifecycle = BigInt(lifecycleRevision);
|
|
604
|
+
const currentLifecycle = BigInt(current.lifecycleRevision);
|
|
605
|
+
if (input.allowAuthoritativeRevisionReset
|
|
606
|
+
&& (incomingEntity < currentEntity || incomingLifecycle < currentLifecycle)) {
|
|
607
|
+
const reset = this.db.prepare(`
|
|
608
|
+
UPDATE workspace_sync_entity_revisions
|
|
609
|
+
SET entity_revision = ?, lifecycle_revision = ?, last_sequence = ?, updated_at = ?
|
|
610
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
611
|
+
AND entity_revision = ? AND lifecycle_revision = ?
|
|
612
|
+
`).run(entityRevision, lifecycleRevision, sequence, now, tenantId, workspaceId, domain, entityId, current.entityRevision, current.lifecycleRevision);
|
|
613
|
+
if (Number(reset.changes || 0) !== 1) {
|
|
614
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
615
|
+
domain,
|
|
616
|
+
entityId,
|
|
617
|
+
reason: 'authoritative-recovery-revision-reset-raced',
|
|
618
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
619
|
+
current,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return { state: 'current' };
|
|
623
|
+
}
|
|
624
|
+
if (incomingEntity < currentEntity)
|
|
625
|
+
return { state: 'stale' };
|
|
626
|
+
if (incomingLifecycle < currentLifecycle) {
|
|
627
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
628
|
+
domain,
|
|
629
|
+
entityId,
|
|
630
|
+
reason: 'lifecycle-revision-regression',
|
|
631
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
632
|
+
current,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (incomingEntity === currentEntity) {
|
|
636
|
+
const coordinatesMatch = current.lifecycleRevision === lifecycleRevision
|
|
637
|
+
&& current.lastSequence === sequence;
|
|
638
|
+
if (!coordinatesMatch)
|
|
639
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
640
|
+
domain,
|
|
641
|
+
entityId,
|
|
642
|
+
reason: 'equal-revision-divergence',
|
|
643
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
644
|
+
current,
|
|
645
|
+
});
|
|
646
|
+
if (current.canonicalFingerprint !== canonicalFingerprint) {
|
|
647
|
+
if (!input.allowAuthoritativeEqualRevisionReplacement) {
|
|
648
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
649
|
+
domain,
|
|
650
|
+
entityId,
|
|
651
|
+
reason: 'equal-revision-divergence',
|
|
652
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
653
|
+
current,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const result = input.applyMutation();
|
|
657
|
+
if (input.shouldRecordRevision?.(result) === false) {
|
|
658
|
+
return { state: 'deferred', result };
|
|
659
|
+
}
|
|
660
|
+
const updated = this.db.prepare(`
|
|
661
|
+
UPDATE workspace_sync_entity_revisions
|
|
662
|
+
SET canonical_fingerprint = ?,
|
|
663
|
+
metadata_owned = CASE WHEN metadata_owned = 1 OR ? = 1 THEN 1 ELSE 0 END,
|
|
664
|
+
reopen_assignee_owned = CASE WHEN reopen_assignee_owned = 1 OR ? = 1 THEN 1 ELSE 0 END,
|
|
665
|
+
updated_at = ?
|
|
666
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
667
|
+
AND entity_revision = ? AND lifecycle_revision = ? AND last_sequence = ?
|
|
668
|
+
`).run(canonicalFingerprint, input.metadataOwned ? 1 : 0, input.reopenAssigneeOwned ? 1 : 0, now, tenantId, workspaceId, domain, entityId, entityRevision, lifecycleRevision, sequence);
|
|
669
|
+
if (Number(updated.changes || 0) !== 1) {
|
|
670
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
671
|
+
domain,
|
|
672
|
+
entityId,
|
|
673
|
+
reason: 'authoritative-equal-revision-replacement-raced',
|
|
674
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return { state: 'applied', result };
|
|
678
|
+
}
|
|
679
|
+
return { state: 'current' };
|
|
680
|
+
}
|
|
681
|
+
if (current.lastSequence !== null && BigInt(sequence) <= BigInt(current.lastSequence)) {
|
|
682
|
+
throw new WorkspaceSyncRevisionConflictError({
|
|
683
|
+
domain,
|
|
684
|
+
entityId,
|
|
685
|
+
reason: 'sequence-regression',
|
|
686
|
+
incoming: { sequence, entityRevision, lifecycleRevision, canonicalFingerprint },
|
|
687
|
+
current,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return applyAuthoritativeRevision();
|
|
691
|
+
})();
|
|
692
|
+
}
|
|
693
|
+
runLocalTaskDeleteCaptureTransaction(input) {
|
|
694
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
695
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
696
|
+
const taskId = requireText(input.taskId, 'Task id');
|
|
697
|
+
const now = requireIsoTimestamp(input.now, 'Delete capture time');
|
|
698
|
+
return this.db.transaction(() => {
|
|
699
|
+
const lock = this.db.prepare(`
|
|
700
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
701
|
+
VALUES (?, ?, ?, ?)
|
|
702
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
703
|
+
`);
|
|
704
|
+
lock.run(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
705
|
+
lock.run(tenantId, workspaceId, 'task-relationship', now);
|
|
706
|
+
lock.run(tenantId, workspaceId, `local-outbox:task:${taskId}`, now);
|
|
707
|
+
return input.apply();
|
|
708
|
+
})();
|
|
709
|
+
}
|
|
710
|
+
runLocalTaskRestoreCaptureTransaction(input) {
|
|
711
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
712
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
713
|
+
const taskId = requireText(input.taskId, 'Task id');
|
|
714
|
+
const now = requireIsoTimestamp(input.now, 'Restore capture time');
|
|
715
|
+
return this.db.transaction(() => {
|
|
716
|
+
const lock = this.db.prepare(`
|
|
717
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
718
|
+
VALUES (?, ?, ?, ?)
|
|
719
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
720
|
+
`);
|
|
721
|
+
lock.run(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
722
|
+
lock.run(tenantId, workspaceId, `local-outbox:task:${taskId}`, now);
|
|
723
|
+
return input.apply();
|
|
724
|
+
})();
|
|
725
|
+
}
|
|
726
|
+
readOperation(input) {
|
|
727
|
+
const ids = operationParams(input.identity);
|
|
728
|
+
const requestFingerprint = requireText(input.requestFingerprint, 'Request fingerprint');
|
|
729
|
+
const row = this.readOperationRow(ids);
|
|
730
|
+
if (!row)
|
|
731
|
+
return null;
|
|
732
|
+
this.assertMatchingOperation(row, requestFingerprint);
|
|
733
|
+
if (row.status === 'committed') {
|
|
734
|
+
if (typeof row.response_json !== 'string')
|
|
735
|
+
throw new Error('Committed operation is missing its response.');
|
|
736
|
+
return {
|
|
737
|
+
state: 'committed',
|
|
738
|
+
responseJson: row.response_json,
|
|
739
|
+
acceptedSequence: row.accepted_sequence == null ? null : normalizeSequence(row.accepted_sequence),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
state: 'in_progress',
|
|
744
|
+
retryAt: requireIsoTimestamp(row.lease_expires_at, 'Existing lease expiry'),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
readCommittedOperationResponse(identity) {
|
|
748
|
+
const row = this.db.prepare(`
|
|
749
|
+
SELECT response_json
|
|
750
|
+
FROM workspace_sync_operation_results
|
|
751
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
752
|
+
AND status = 'committed'
|
|
753
|
+
`).get(...operationParams(identity));
|
|
754
|
+
if (!row)
|
|
755
|
+
return null;
|
|
756
|
+
if (typeof row.response_json !== 'string')
|
|
757
|
+
throw new Error('Committed operation is missing its response.');
|
|
758
|
+
return row.response_json;
|
|
759
|
+
}
|
|
760
|
+
readOperationReservationMetadata(identity) {
|
|
761
|
+
const row = this.db.prepare(`
|
|
762
|
+
SELECT status, created_at
|
|
763
|
+
FROM workspace_sync_operation_results
|
|
764
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
765
|
+
`).get(...operationParams(identity));
|
|
766
|
+
if (!row)
|
|
767
|
+
return null;
|
|
768
|
+
if (row.status !== 'processing' && row.status !== 'committed') {
|
|
769
|
+
throw new Error('Workspace sync operation status is invalid.');
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
status: row.status,
|
|
773
|
+
createdAt: requireIsoTimestamp(row.created_at, 'Operation reservation creation time'),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
readOutboxOperation(identity) {
|
|
777
|
+
const row = this.db.prepare(`
|
|
778
|
+
SELECT tenant_id, workspace_id, client_id, operation_id, dispatch_class, domain, entity_id,
|
|
779
|
+
mutation_kind, owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
780
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
781
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
782
|
+
payload_json, payload_fingerprint, status, attempt_count,
|
|
783
|
+
initial_not_found_attempt_count,
|
|
784
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence,
|
|
785
|
+
rebase_before_first_attempt, lease_owner, lease_expires_at
|
|
786
|
+
FROM workspace_sync_outbox
|
|
787
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
788
|
+
`).get(...operationParams(identity));
|
|
789
|
+
return row ? this.mapOutboxRow(row) : null;
|
|
790
|
+
}
|
|
791
|
+
readV3DispatchSettlementEvidence(identity) {
|
|
792
|
+
const row = this.db.prepare(`
|
|
793
|
+
SELECT outbox.status, outbox.authoritative_response_json,
|
|
794
|
+
CAST(outbox.accepted_sequence AS TEXT) AS accepted_sequence,
|
|
795
|
+
outbox.recovery_batch_id, recovery.state AS recovery_state,
|
|
796
|
+
recovery.baseline_marker, recovery.completed_at
|
|
797
|
+
FROM workspace_sync_outbox AS outbox
|
|
798
|
+
LEFT JOIN workspace_sync_outbox_recoveries AS recovery
|
|
799
|
+
ON recovery.tenant_id = outbox.tenant_id
|
|
800
|
+
AND recovery.workspace_id = outbox.workspace_id
|
|
801
|
+
AND recovery.recovery_batch_id = outbox.recovery_batch_id
|
|
802
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
803
|
+
AND outbox.client_id = ? AND outbox.operation_id = ?
|
|
804
|
+
`).get(...operationParams(identity));
|
|
805
|
+
if (!row)
|
|
806
|
+
return null;
|
|
807
|
+
if (row.status === 'accepted' && typeof row.authoritative_response_json === 'string') {
|
|
808
|
+
return {
|
|
809
|
+
kind: 'accepted',
|
|
810
|
+
acceptedSequence: row.accepted_sequence == null
|
|
811
|
+
? null
|
|
812
|
+
: String(row.accepted_sequence),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (row.status === 'superseded'
|
|
816
|
+
&& row.recovery_state === 'completed'
|
|
817
|
+
&& typeof row.recovery_batch_id === 'string'
|
|
818
|
+
&& typeof row.baseline_marker === 'string'
|
|
819
|
+
&& typeof row.completed_at === 'string') {
|
|
820
|
+
return {
|
|
821
|
+
kind: 'completed-recovery',
|
|
822
|
+
recoveryBatchId: row.recovery_batch_id,
|
|
823
|
+
baselineMarker: row.baseline_marker,
|
|
824
|
+
completedAt: requireIsoTimestamp(row.completed_at, 'Outbox recovery completion time'),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
listBlockedInvalidOutboxOperations(input) {
|
|
830
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
831
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
832
|
+
const limit = Math.max(1, Math.min(100, Math.floor(input.limit ?? 100)));
|
|
833
|
+
const rows = this.db.prepare(`
|
|
834
|
+
SELECT tenant_id, workspace_id, client_id, operation_id, dispatch_class, domain, entity_id,
|
|
835
|
+
mutation_kind, owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
836
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
837
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
838
|
+
payload_json, payload_fingerprint, status, attempt_count,
|
|
839
|
+
initial_not_found_attempt_count,
|
|
840
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence,
|
|
841
|
+
rebase_before_first_attempt, lease_owner, lease_expires_at
|
|
842
|
+
FROM workspace_sync_outbox
|
|
843
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
844
|
+
AND contract_version = ?
|
|
845
|
+
AND last_error_code = 'WORKSPACE_SYNC_OUTBOX_INVALID'
|
|
846
|
+
ORDER BY enqueue_sequence
|
|
847
|
+
LIMIT ?
|
|
848
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, limit);
|
|
849
|
+
return rows.map((row) => this.mapOutboxRow(row));
|
|
850
|
+
}
|
|
851
|
+
requeueBlockedInvalidOutboxOperation(input) {
|
|
852
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
853
|
+
const result = this.db.prepare(`
|
|
854
|
+
UPDATE workspace_sync_outbox
|
|
855
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
856
|
+
last_error_code = 'WORKSPACE_SYNC_OUTBOX_CONTRACT_UPGRADE_RECOVERY',
|
|
857
|
+
last_error_message = 'Requeued after the current local contract accepted the persisted operation.',
|
|
858
|
+
updated_at = ?
|
|
859
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
860
|
+
AND contract_version = ?
|
|
861
|
+
AND status = 'blocked' AND last_error_code = 'WORKSPACE_SYNC_OUTBOX_INVALID'
|
|
862
|
+
`).run(now, now, ...operationParams(input.identity), WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
863
|
+
return Number(result.changes || 0) === 1;
|
|
864
|
+
}
|
|
865
|
+
requeueAcceptedV3DispatchOperation(input) {
|
|
866
|
+
const now = requireIsoTimestamp(input.now, 'Accepted replay recovery time');
|
|
867
|
+
const result = this.db.prepare(`
|
|
868
|
+
UPDATE workspace_sync_outbox
|
|
869
|
+
SET status = 'pending', next_attempt_at = ?,
|
|
870
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
871
|
+
accepted_replay_recovery_count = accepted_replay_recovery_count + 1,
|
|
872
|
+
last_error_code = 'WORKSPACE_SYNC_ACCEPTED_REPLAY_RECOVERY',
|
|
873
|
+
last_error_message = 'Requeued the exact operation identity after the coordinator proved upstream acceptance before local acknowledgement committed.',
|
|
874
|
+
updated_at = ?
|
|
875
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
876
|
+
AND contract_version = ?
|
|
877
|
+
AND status = 'blocked'
|
|
878
|
+
AND accepted_replay_recovery_count < 1
|
|
879
|
+
AND recovery_batch_id IS NULL
|
|
880
|
+
`).run(now, now, ...operationParams(input.identity), WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
881
|
+
return Number(result.changes || 0) === 1;
|
|
882
|
+
}
|
|
883
|
+
requeueWorkstreamInitiativeDependencyAcknowledgements(input) {
|
|
884
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
885
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
886
|
+
const now = requireIsoTimestamp(input.now, 'Workstream dependency recovery time');
|
|
887
|
+
const result = this.db.prepare(`
|
|
888
|
+
UPDATE workspace_sync_outbox
|
|
889
|
+
SET status = 'pending', next_attempt_at = ?,
|
|
890
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
891
|
+
accepted_replay_recovery_count = accepted_replay_recovery_count + 1,
|
|
892
|
+
last_error_code = 'WORKSPACE_SYNC_WORKSTREAM_INITIATIVE_DEPENDENCY_RECOVERY',
|
|
893
|
+
last_error_message = 'Requeued the accepted workstream once after authoritative parent dependency support became available.',
|
|
894
|
+
updated_at = ?
|
|
895
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
896
|
+
AND contract_version = ?
|
|
897
|
+
AND status = 'blocked'
|
|
898
|
+
AND domain = 'workstream' AND mutation_kind = 'metadata-update'
|
|
899
|
+
AND last_error_code = 'INITIATIVE_NOT_FOUND'
|
|
900
|
+
AND workstream_metadata_revision_rebase_count = 1
|
|
901
|
+
AND accepted_replay_recovery_count = 1
|
|
902
|
+
AND recovery_batch_id IS NULL
|
|
903
|
+
`).run(now, now, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
904
|
+
return Number(result.changes || 0);
|
|
905
|
+
}
|
|
906
|
+
readWorkflowAssignmentDependencyState(input) {
|
|
907
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
908
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
909
|
+
const executionId = requireText(input.executionId, 'Workflow execution id');
|
|
910
|
+
const beforeEnqueueSequence = normalizeSequence(input.beforeEnqueueSequence);
|
|
911
|
+
const rows = this.db.prepare(`
|
|
912
|
+
SELECT status, payload_json
|
|
913
|
+
FROM workspace_sync_outbox
|
|
914
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
915
|
+
AND domain = 'task' AND mutation_kind = 'set-status'
|
|
916
|
+
AND status IN ('pending', 'in_flight', 'blocked')
|
|
917
|
+
AND enqueue_sequence < ?
|
|
918
|
+
ORDER BY CASE status WHEN 'blocked' THEN 0 ELSE 1 END, enqueue_sequence
|
|
919
|
+
`).all(tenantId, workspaceId, beforeEnqueueSequence);
|
|
920
|
+
for (const row of rows) {
|
|
921
|
+
try {
|
|
922
|
+
const payload = JSON.parse(String(row.payload_json || ''));
|
|
923
|
+
if (String(payload.workflowAssignment?.execution?.id || '') !== executionId)
|
|
924
|
+
continue;
|
|
925
|
+
return row.status === 'blocked' ? 'blocked' : 'pending';
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
// Invalid payloads are handled by their owning outbox handler.
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
getActiveEntityBusyError(input) {
|
|
934
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
935
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
936
|
+
const target = {
|
|
937
|
+
domain: requireText(input.target.domain, 'Target domain'),
|
|
938
|
+
entityId: requireText(input.target.entityId, 'Target entity id'),
|
|
939
|
+
};
|
|
940
|
+
const mutationKind = String(input.mutationKind || '').trim();
|
|
941
|
+
const now = requireIsoTimestamp(input.now, 'Current time');
|
|
942
|
+
const active = this.db.prepare(`
|
|
943
|
+
SELECT outbox.operation_id, outbox.domain, outbox.entity_id, outbox.mutation_kind,
|
|
944
|
+
outbox.status, outbox.attempt_count, outbox.next_attempt_at,
|
|
945
|
+
outbox.lease_expires_at, outbox.last_error_code,
|
|
946
|
+
outbox.created_at, outbox.updated_at
|
|
947
|
+
FROM workspace_sync_outbox AS outbox
|
|
948
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
949
|
+
AND outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
950
|
+
AND (? = '' OR outbox.mutation_kind = ?)
|
|
951
|
+
AND (
|
|
952
|
+
(outbox.domain = ? AND outbox.entity_id = ?)
|
|
953
|
+
OR EXISTS (
|
|
954
|
+
SELECT 1 FROM workspace_sync_outbox_targets AS persisted_target
|
|
955
|
+
WHERE persisted_target.tenant_id = outbox.tenant_id
|
|
956
|
+
AND persisted_target.workspace_id = outbox.workspace_id
|
|
957
|
+
AND persisted_target.client_id = outbox.client_id
|
|
958
|
+
AND persisted_target.operation_id = outbox.operation_id
|
|
959
|
+
AND persisted_target.domain = ? AND persisted_target.entity_id = ?
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
ORDER BY CASE outbox.status
|
|
963
|
+
WHEN 'blocked' THEN 0
|
|
964
|
+
WHEN 'in_flight' THEN 1
|
|
965
|
+
ELSE 2
|
|
966
|
+
END,
|
|
967
|
+
outbox.enqueue_sequence
|
|
968
|
+
`).all(tenantId, workspaceId, mutationKind, mutationKind, target.domain, target.entityId, target.domain, target.entityId);
|
|
969
|
+
return active.length > 0
|
|
970
|
+
? workspaceSyncEntityBusyError({ target, active, now })
|
|
971
|
+
: null;
|
|
972
|
+
}
|
|
973
|
+
hasActiveOutboxSuccessor(identity) {
|
|
974
|
+
const ids = operationParams(identity);
|
|
975
|
+
const current = this.db.prepare(`
|
|
976
|
+
SELECT domain, entity_id, enqueue_sequence
|
|
977
|
+
FROM workspace_sync_outbox
|
|
978
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
979
|
+
`).get(...ids);
|
|
980
|
+
if (!current)
|
|
981
|
+
throw new Error('Workspace sync outbox operation does not exist.');
|
|
982
|
+
const successor = this.db.prepare(`
|
|
983
|
+
SELECT 1 AS active
|
|
984
|
+
FROM workspace_sync_outbox AS successor
|
|
985
|
+
WHERE successor.tenant_id = ? AND successor.workspace_id = ?
|
|
986
|
+
AND successor.status IN ('pending', 'in_flight', 'blocked')
|
|
987
|
+
AND successor.enqueue_sequence > ?
|
|
988
|
+
AND EXISTS (
|
|
989
|
+
SELECT 1
|
|
990
|
+
FROM workspace_sync_outbox_targets AS current_target
|
|
991
|
+
INNER JOIN workspace_sync_outbox_targets AS successor_target
|
|
992
|
+
ON successor_target.tenant_id = current_target.tenant_id
|
|
993
|
+
AND successor_target.workspace_id = current_target.workspace_id
|
|
994
|
+
AND successor_target.domain = current_target.domain
|
|
995
|
+
AND successor_target.entity_id = current_target.entity_id
|
|
996
|
+
WHERE current_target.tenant_id = ? AND current_target.workspace_id = ?
|
|
997
|
+
AND current_target.client_id = ? AND current_target.operation_id = ?
|
|
998
|
+
AND successor_target.client_id = successor.client_id
|
|
999
|
+
AND successor_target.operation_id = successor.operation_id
|
|
1000
|
+
)
|
|
1001
|
+
LIMIT 1
|
|
1002
|
+
`).get(ids[0], ids[1], normalizeSequence(current.enqueue_sequence ?? 0), ...ids);
|
|
1003
|
+
return Number(successor?.active || 0) === 1;
|
|
1004
|
+
}
|
|
1005
|
+
listActiveOutboxSuccessors(identity) {
|
|
1006
|
+
const ids = operationParams(identity);
|
|
1007
|
+
const current = this.db.prepare(`
|
|
1008
|
+
SELECT domain, entity_id, enqueue_sequence
|
|
1009
|
+
FROM workspace_sync_outbox
|
|
1010
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1011
|
+
`).get(...ids);
|
|
1012
|
+
if (!current)
|
|
1013
|
+
throw new Error('Workspace sync outbox operation does not exist.');
|
|
1014
|
+
const rows = this.db.prepare(`
|
|
1015
|
+
SELECT tenant_id, workspace_id, client_id, operation_id, dispatch_class, domain, entity_id,
|
|
1016
|
+
mutation_kind, owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
1017
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
1018
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
1019
|
+
payload_json, payload_fingerprint, status, attempt_count,
|
|
1020
|
+
initial_not_found_attempt_count,
|
|
1021
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence,
|
|
1022
|
+
rebase_before_first_attempt, lease_owner, lease_expires_at
|
|
1023
|
+
FROM workspace_sync_outbox
|
|
1024
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
1025
|
+
AND status IN ('pending', 'in_flight', 'blocked')
|
|
1026
|
+
AND enqueue_sequence > ?
|
|
1027
|
+
AND EXISTS (
|
|
1028
|
+
SELECT 1
|
|
1029
|
+
FROM workspace_sync_outbox_targets AS current_target
|
|
1030
|
+
INNER JOIN workspace_sync_outbox_targets AS successor_target
|
|
1031
|
+
ON successor_target.tenant_id = current_target.tenant_id
|
|
1032
|
+
AND successor_target.workspace_id = current_target.workspace_id
|
|
1033
|
+
AND successor_target.domain = current_target.domain
|
|
1034
|
+
AND successor_target.entity_id = current_target.entity_id
|
|
1035
|
+
WHERE current_target.tenant_id = ? AND current_target.workspace_id = ?
|
|
1036
|
+
AND current_target.client_id = ? AND current_target.operation_id = ?
|
|
1037
|
+
AND successor_target.client_id = workspace_sync_outbox.client_id
|
|
1038
|
+
AND successor_target.operation_id = workspace_sync_outbox.operation_id
|
|
1039
|
+
)
|
|
1040
|
+
ORDER BY enqueue_sequence
|
|
1041
|
+
`).all(ids[0], ids[1], normalizeSequence(current.enqueue_sequence ?? 0), ...ids);
|
|
1042
|
+
return rows.map((row) => this.mapOutboxRow(row));
|
|
1043
|
+
}
|
|
1044
|
+
supersedeBlockedOutboxChain(input) {
|
|
1045
|
+
const ids = operationParams(input.identity);
|
|
1046
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1047
|
+
const now = requireIsoTimestamp(input.now, 'Supersession time');
|
|
1048
|
+
const matchesRecoveryBatch = (recovery) => Boolean(recovery
|
|
1049
|
+
&& recovery.state === 'awaiting_baseline'
|
|
1050
|
+
&& (input.appendToExistingRecovery
|
|
1051
|
+
|| (recovery.root_client_id === ids[2] && recovery.root_operation_id === ids[3])));
|
|
1052
|
+
const listRecoveryOperationIds = () => {
|
|
1053
|
+
const rows = this.db.prepare(`
|
|
1054
|
+
SELECT operation_id
|
|
1055
|
+
FROM workspace_sync_outbox
|
|
1056
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1057
|
+
AND status = 'superseded'
|
|
1058
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
1059
|
+
`).all(ids[0], ids[1], recoveryBatchId);
|
|
1060
|
+
return rows.map((row) => requireText(row.operation_id, 'Operation id'));
|
|
1061
|
+
};
|
|
1062
|
+
return this.db.transaction(() => {
|
|
1063
|
+
const root = this.db.prepare(`
|
|
1064
|
+
SELECT status, recovery_batch_id,
|
|
1065
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence
|
|
1066
|
+
FROM workspace_sync_outbox
|
|
1067
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1068
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1069
|
+
`).get(...ids);
|
|
1070
|
+
if (!root)
|
|
1071
|
+
throw new Error('Workspace sync outbox operation does not exist.');
|
|
1072
|
+
if (root.status === 'superseded') {
|
|
1073
|
+
if (root.recovery_batch_id !== recoveryBatchId) {
|
|
1074
|
+
throw new Error('Workspace sync outbox operation belongs to a different recovery batch.');
|
|
1075
|
+
}
|
|
1076
|
+
const recovery = this.db.prepare(`
|
|
1077
|
+
SELECT root_client_id, root_operation_id, state
|
|
1078
|
+
FROM workspace_sync_outbox_recoveries
|
|
1079
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1080
|
+
`).get(ids[0], ids[1], recoveryBatchId);
|
|
1081
|
+
if (!matchesRecoveryBatch(recovery)) {
|
|
1082
|
+
throw new Error('Workspace sync outbox recovery batch root does not match.');
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
recoveryBatchId,
|
|
1086
|
+
operationIds: listRecoveryOperationIds(),
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
if (root.status !== 'blocked') {
|
|
1090
|
+
throw new Error('Only a blocked workspace sync operation can start recovery.');
|
|
1091
|
+
}
|
|
1092
|
+
const rootKey = `${ids[2]}\0${ids[3]}`;
|
|
1093
|
+
const affectedOperationKeys = new Set([rootKey]);
|
|
1094
|
+
const affectedTargets = new Set();
|
|
1095
|
+
const lockedTargets = new Set();
|
|
1096
|
+
const lockTarget = this.db.prepare(`
|
|
1097
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
1098
|
+
VALUES (?, ?, ?, ?)
|
|
1099
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
1100
|
+
`);
|
|
1101
|
+
let candidates = [];
|
|
1102
|
+
let targetsByOperation = new Map();
|
|
1103
|
+
while (true) {
|
|
1104
|
+
const targets = this.db.prepare(`
|
|
1105
|
+
SELECT client_id, operation_id, domain, entity_id
|
|
1106
|
+
FROM workspace_sync_outbox_targets
|
|
1107
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
1108
|
+
`).all(ids[0], ids[1]);
|
|
1109
|
+
targetsByOperation = new Map();
|
|
1110
|
+
for (const target of targets) {
|
|
1111
|
+
const operationKey = `${requireText(target.client_id, 'Client id')}\0${requireText(target.operation_id, 'Operation id')}`;
|
|
1112
|
+
const operationTargets = targetsByOperation.get(operationKey) || new Set();
|
|
1113
|
+
operationTargets.add(`${requireText(target.domain, 'Domain')}\0${requireText(target.entity_id, 'Entity id')}`);
|
|
1114
|
+
targetsByOperation.set(operationKey, operationTargets);
|
|
1115
|
+
}
|
|
1116
|
+
for (const target of targetsByOperation.get(rootKey) || [])
|
|
1117
|
+
affectedTargets.add(target);
|
|
1118
|
+
if (affectedTargets.size === 0) {
|
|
1119
|
+
throw new Error('Blocked workspace sync recovery root has no durable targets.');
|
|
1120
|
+
}
|
|
1121
|
+
const unlockedTargets = [...affectedTargets]
|
|
1122
|
+
.filter((target) => !lockedTargets.has(target))
|
|
1123
|
+
.sort(compareCodeUnits);
|
|
1124
|
+
for (const target of unlockedTargets) {
|
|
1125
|
+
const separator = target.indexOf('\0');
|
|
1126
|
+
const domain = target.slice(0, separator);
|
|
1127
|
+
const entityId = target.slice(separator + 1);
|
|
1128
|
+
lockTarget.run(ids[0], ids[1], `local-outbox:${domain}:${entityId}`, now);
|
|
1129
|
+
lockedTargets.add(target);
|
|
1130
|
+
}
|
|
1131
|
+
candidates = this.db.prepare(`
|
|
1132
|
+
SELECT client_id, operation_id, dispatch_class, domain, entity_id, mutation_kind,
|
|
1133
|
+
payload_json, payload_fingerprint, status, lease_expires_at,
|
|
1134
|
+
last_error_code, last_error_message,
|
|
1135
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence,
|
|
1136
|
+
created_at
|
|
1137
|
+
FROM workspace_sync_outbox
|
|
1138
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
1139
|
+
AND status IN ('pending', 'in_flight', 'blocked')
|
|
1140
|
+
AND enqueue_sequence >= ?
|
|
1141
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
1142
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1143
|
+
`).all(ids[0], ids[1], normalizeSequence(root.enqueue_sequence ?? 0));
|
|
1144
|
+
let changed = true;
|
|
1145
|
+
while (changed) {
|
|
1146
|
+
changed = false;
|
|
1147
|
+
for (const candidate of candidates) {
|
|
1148
|
+
const candidateKey = `${requireText(candidate.client_id, 'Client id')}\0${requireText(candidate.operation_id, 'Operation id')}`;
|
|
1149
|
+
if (affectedOperationKeys.has(candidateKey))
|
|
1150
|
+
continue;
|
|
1151
|
+
const candidateTargets = targetsByOperation.get(candidateKey) || new Set();
|
|
1152
|
+
if (![...candidateTargets].some((target) => affectedTargets.has(target)))
|
|
1153
|
+
continue;
|
|
1154
|
+
affectedOperationKeys.add(candidateKey);
|
|
1155
|
+
for (const target of candidateTargets)
|
|
1156
|
+
affectedTargets.add(target);
|
|
1157
|
+
changed = true;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if ([...affectedTargets].every((target) => lockedTargets.has(target)))
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
const selected = candidates.filter((candidate) => affectedOperationKeys.has(`${requireText(candidate.client_id, 'Client id')}\0${requireText(candidate.operation_id, 'Operation id')}`));
|
|
1164
|
+
if (!selected.some((candidate) => (candidate.client_id === ids[2] && candidate.operation_id === ids[3]))) {
|
|
1165
|
+
throw new Error('Blocked workspace sync recovery root was not selected.');
|
|
1166
|
+
}
|
|
1167
|
+
const activeLease = selected.find((candidate) => (candidate.status === 'in_flight'
|
|
1168
|
+
&& Date.parse(String(candidate.lease_expires_at || '')) > Date.parse(now)));
|
|
1169
|
+
if (activeLease) {
|
|
1170
|
+
throw new Error('A workspace sync operation in the recovery chain still owns an active lease.');
|
|
1171
|
+
}
|
|
1172
|
+
if (input.replayPolicy === 'task-root') {
|
|
1173
|
+
for (const candidate of selected) {
|
|
1174
|
+
const supportedTaskRoot = candidate.domain === 'task'
|
|
1175
|
+
&& TASK_ROOT_SUCCESSOR_MUTATIONS.has(String(candidate.mutation_kind || ''));
|
|
1176
|
+
const supportedTaskComment = candidate.domain === 'task-comments'
|
|
1177
|
+
&& candidate.mutation_kind === 'append';
|
|
1178
|
+
const supportedTaskCreate = candidate.domain === 'task'
|
|
1179
|
+
&& candidate.mutation_kind === 'create'
|
|
1180
|
+
&& TASK_CREATE_RECOVERY_ERROR_CODES.has(String(candidate.last_error_code || ''));
|
|
1181
|
+
const supportedTaskAttachmentLinks = candidate.domain === 'task-attachment-links'
|
|
1182
|
+
&& candidate.mutation_kind === 'replace';
|
|
1183
|
+
const supportedEntityEventAdoption = candidate.dispatch_class === 'legacy-adoption'
|
|
1184
|
+
&& candidate.domain === 'entity-event'
|
|
1185
|
+
&& candidate.mutation_kind === 'adopt-legacy-batch';
|
|
1186
|
+
if (!supportedTaskRoot && !supportedTaskComment
|
|
1187
|
+
&& !supportedTaskCreate && !supportedTaskAttachmentLinks
|
|
1188
|
+
&& !supportedEntityEventAdoption) {
|
|
1189
|
+
throw new Error('Workspace sync recovery replay currently supports task-root, task-comment append, '
|
|
1190
|
+
+ 'task-create attachment-link, and legacy entity-event adoption intents only.');
|
|
1191
|
+
}
|
|
1192
|
+
if (supportedTaskCreate)
|
|
1193
|
+
this.readTaskCreateRecoveryIntent(candidate);
|
|
1194
|
+
else if (supportedTaskAttachmentLinks) {
|
|
1195
|
+
this.readTaskAttachmentLinksRecoveryIntent(candidate);
|
|
1196
|
+
}
|
|
1197
|
+
else if (supportedTaskComment
|
|
1198
|
+
&& (candidate.client_id !== ids[2] || candidate.operation_id !== ids[3])) {
|
|
1199
|
+
// A successor comment still owns the active operation that will be
|
|
1200
|
+
// superseded with this chain. Validate its frozen intent now, then
|
|
1201
|
+
// require authoritative event ownership when the replay is prepared.
|
|
1202
|
+
this.readTaskCommentAppendRecoveryIntent(ids[1], candidate);
|
|
1203
|
+
}
|
|
1204
|
+
else {
|
|
1205
|
+
this.prepareRecoveryPayload(ids[0], ids[1], candidate);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
this.db.prepare(`
|
|
1210
|
+
INSERT INTO workspace_sync_outbox_recoveries (
|
|
1211
|
+
tenant_id, workspace_id, recovery_batch_id,
|
|
1212
|
+
root_client_id, root_operation_id, state,
|
|
1213
|
+
created_at, updated_at
|
|
1214
|
+
) VALUES (?, ?, ?, ?, ?, 'awaiting_baseline', ?, ?)
|
|
1215
|
+
ON CONFLICT (tenant_id, workspace_id, recovery_batch_id) DO NOTHING
|
|
1216
|
+
`).run(ids[0], ids[1], recoveryBatchId, ids[2], ids[3], now, now);
|
|
1217
|
+
const recovery = this.db.prepare(`
|
|
1218
|
+
SELECT root_client_id, root_operation_id, state
|
|
1219
|
+
FROM workspace_sync_outbox_recoveries
|
|
1220
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1221
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1222
|
+
`).get(ids[0], ids[1], recoveryBatchId);
|
|
1223
|
+
if (!matchesRecoveryBatch(recovery)) {
|
|
1224
|
+
throw new Error('Workspace sync recovery batch identity is already in use.');
|
|
1225
|
+
}
|
|
1226
|
+
for (const candidate of selected) {
|
|
1227
|
+
const clientId = requireText(candidate.client_id, 'Client id');
|
|
1228
|
+
const operationId = requireText(candidate.operation_id, 'Operation id');
|
|
1229
|
+
const result = this.db.prepare(`
|
|
1230
|
+
UPDATE workspace_sync_outbox
|
|
1231
|
+
SET status = 'superseded', recovery_batch_id = ?, superseded_at = ?,
|
|
1232
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
1233
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1234
|
+
AND (
|
|
1235
|
+
status IN ('pending', 'blocked')
|
|
1236
|
+
OR (status = 'in_flight' AND lease_expires_at <= ?)
|
|
1237
|
+
)
|
|
1238
|
+
`).run(recoveryBatchId, now, now, ids[0], ids[1], clientId, operationId, now);
|
|
1239
|
+
if (Number(result.changes || 0) !== 1) {
|
|
1240
|
+
throw new Error('Workspace sync recovery chain changed while it was being superseded.');
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
recoveryBatchId,
|
|
1245
|
+
operationIds: listRecoveryOperationIds(),
|
|
1246
|
+
};
|
|
1247
|
+
})();
|
|
1248
|
+
}
|
|
1249
|
+
recordOutboxRecoveryBaseline(input) {
|
|
1250
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1251
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1252
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1253
|
+
const baselineMarker = requireText(input.baselineMarker, 'Recovery baseline marker');
|
|
1254
|
+
const now = requireIsoTimestamp(input.now, 'Recovery baseline time');
|
|
1255
|
+
this.db.transaction(() => {
|
|
1256
|
+
const recovery = this.db.prepare(`
|
|
1257
|
+
SELECT state, baseline_marker
|
|
1258
|
+
FROM workspace_sync_outbox_recoveries
|
|
1259
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1260
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1261
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
1262
|
+
if (!recovery)
|
|
1263
|
+
throw new Error('Workspace sync recovery batch does not exist.');
|
|
1264
|
+
if (recovery.state === 'baseline_ready' || recovery.state === 'replay_queued') {
|
|
1265
|
+
if (recovery.baseline_marker !== baselineMarker) {
|
|
1266
|
+
throw new Error('Workspace sync recovery baseline marker does not match.');
|
|
1267
|
+
}
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (recovery.state !== 'awaiting_baseline') {
|
|
1271
|
+
throw new Error('Workspace sync recovery is not waiting for an authoritative baseline.');
|
|
1272
|
+
}
|
|
1273
|
+
const result = this.db.prepare(`
|
|
1274
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
1275
|
+
SET state = 'baseline_ready', baseline_marker = ?,
|
|
1276
|
+
baseline_applied_at = ?, updated_at = ?
|
|
1277
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1278
|
+
AND state = 'awaiting_baseline'
|
|
1279
|
+
`).run(baselineMarker, now, now, tenantId, workspaceId, recoveryBatchId);
|
|
1280
|
+
if (Number(result.changes || 0) !== 1) {
|
|
1281
|
+
throw new Error('Workspace sync recovery phase changed while recording its baseline.');
|
|
1282
|
+
}
|
|
1283
|
+
})();
|
|
1284
|
+
}
|
|
1285
|
+
listOutboxRecoveryTargets(input) {
|
|
1286
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1287
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1288
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1289
|
+
const rows = this.db.prepare(`
|
|
1290
|
+
SELECT DISTINCT target.domain, target.entity_id
|
|
1291
|
+
FROM workspace_sync_outbox AS source
|
|
1292
|
+
INNER JOIN workspace_sync_outbox_targets AS target
|
|
1293
|
+
ON target.tenant_id = source.tenant_id
|
|
1294
|
+
AND target.workspace_id = source.workspace_id
|
|
1295
|
+
AND target.client_id = source.client_id
|
|
1296
|
+
AND target.operation_id = source.operation_id
|
|
1297
|
+
WHERE source.tenant_id = ? AND source.workspace_id = ?
|
|
1298
|
+
AND source.recovery_batch_id = ?
|
|
1299
|
+
AND source.status = 'superseded'
|
|
1300
|
+
AND source.replay_of_operation_id IS NULL
|
|
1301
|
+
ORDER BY target.domain, target.entity_id
|
|
1302
|
+
`).all(tenantId, workspaceId, recoveryBatchId);
|
|
1303
|
+
if (rows.length === 0)
|
|
1304
|
+
throw new Error('Workspace sync recovery snapshot has no targets.');
|
|
1305
|
+
return rows.map((row) => ({
|
|
1306
|
+
domain: requireText(row.domain, 'Recovery target domain'),
|
|
1307
|
+
entityId: requireText(row.entity_id, 'Recovery target entity id'),
|
|
1308
|
+
}));
|
|
1309
|
+
}
|
|
1310
|
+
prepareTaskRootOutboxRecoveryReplay(input) {
|
|
1311
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1312
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1313
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1314
|
+
const now = requireIsoTimestamp(input.now, 'Recovery replay time');
|
|
1315
|
+
return this.db.transaction(() => {
|
|
1316
|
+
const recovery = this.db.prepare(`
|
|
1317
|
+
SELECT state, baseline_marker
|
|
1318
|
+
FROM workspace_sync_outbox_recoveries
|
|
1319
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1320
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1321
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
1322
|
+
if (!recovery)
|
|
1323
|
+
throw new Error('Workspace sync recovery batch does not exist.');
|
|
1324
|
+
const listReplayOperationIds = () => {
|
|
1325
|
+
const rows = this.db.prepare(`
|
|
1326
|
+
SELECT operation_id
|
|
1327
|
+
FROM workspace_sync_outbox
|
|
1328
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1329
|
+
AND replay_of_operation_id IS NOT NULL
|
|
1330
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
1331
|
+
`).all(tenantId, workspaceId, recoveryBatchId);
|
|
1332
|
+
return rows.map((row) => requireText(row.operation_id, 'Replay operation id'));
|
|
1333
|
+
};
|
|
1334
|
+
if (recovery.state === 'replay_queued') {
|
|
1335
|
+
return { recoveryBatchId, operationIds: listReplayOperationIds() };
|
|
1336
|
+
}
|
|
1337
|
+
if (recovery.state !== 'baseline_ready') {
|
|
1338
|
+
throw new Error('Workspace sync recovery requires an authoritative baseline before replay.');
|
|
1339
|
+
}
|
|
1340
|
+
const baselineMarker = requireText(recovery.baseline_marker, 'Workspace sync recovery baseline marker');
|
|
1341
|
+
const recoverySources = this.db.prepare(`
|
|
1342
|
+
SELECT client_id, operation_id, dispatch_class, domain, entity_id, mutation_kind,
|
|
1343
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
1344
|
+
payload_json, payload_fingerprint, last_error_code, last_error_message,
|
|
1345
|
+
EXISTS (
|
|
1346
|
+
SELECT 1
|
|
1347
|
+
FROM workspace_sync_outbox AS replay_candidate
|
|
1348
|
+
WHERE replay_candidate.tenant_id = workspace_sync_outbox.tenant_id
|
|
1349
|
+
AND replay_candidate.workspace_id = workspace_sync_outbox.workspace_id
|
|
1350
|
+
AND replay_candidate.recovery_batch_id = workspace_sync_outbox.recovery_batch_id
|
|
1351
|
+
AND replay_candidate.replay_of_client_id = workspace_sync_outbox.client_id
|
|
1352
|
+
AND replay_candidate.replay_of_operation_id = workspace_sync_outbox.operation_id
|
|
1353
|
+
AND replay_candidate.status = 'superseded'
|
|
1354
|
+
AND (
|
|
1355
|
+
(
|
|
1356
|
+
workspace_sync_outbox.mutation_kind = 'archive'
|
|
1357
|
+
AND replay_candidate.last_error_code = 'TASK_LIFECYCLE_ALREADY_ARCHIVED'
|
|
1358
|
+
)
|
|
1359
|
+
OR (
|
|
1360
|
+
workspace_sync_outbox.mutation_kind = 'unarchive'
|
|
1361
|
+
AND replay_candidate.last_error_code = 'TASK_LIFECYCLE_NOT_ARCHIVED'
|
|
1362
|
+
)
|
|
1363
|
+
OR (
|
|
1364
|
+
workspace_sync_outbox.mutation_kind = 'archive'
|
|
1365
|
+
AND replay_candidate.last_error_code = 'TASK_LIFECYCLE_STATE_CONFLICT'
|
|
1366
|
+
AND replay_candidate.last_error_message = 'Task is already archived.'
|
|
1367
|
+
)
|
|
1368
|
+
OR (
|
|
1369
|
+
workspace_sync_outbox.mutation_kind = 'unarchive'
|
|
1370
|
+
AND replay_candidate.last_error_code = 'TASK_LIFECYCLE_STATE_CONFLICT'
|
|
1371
|
+
AND replay_candidate.last_error_message = 'Task is not archived.'
|
|
1372
|
+
)
|
|
1373
|
+
)
|
|
1374
|
+
) AS baseline_satisfaction_candidate,
|
|
1375
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence
|
|
1376
|
+
FROM workspace_sync_outbox
|
|
1377
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1378
|
+
AND status = 'superseded' AND replay_of_operation_id IS NULL
|
|
1379
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
1380
|
+
`).all(tenantId, workspaceId, recoveryBatchId);
|
|
1381
|
+
for (const source of recoverySources) {
|
|
1382
|
+
if (source.domain === 'task-comments' && source.mutation_kind === 'append') {
|
|
1383
|
+
this.assertTaskCommentAppendRecoveryEventOwned(tenantId, workspaceId, source);
|
|
1384
|
+
}
|
|
1385
|
+
else if (source.domain === 'task' && source.mutation_kind === 'create') {
|
|
1386
|
+
this.readTaskCreateRecoveryIntent(source);
|
|
1387
|
+
}
|
|
1388
|
+
else if (source.domain === 'task-attachment-links'
|
|
1389
|
+
&& source.mutation_kind === 'replace') {
|
|
1390
|
+
this.readTaskAttachmentLinksRecoveryIntent(source);
|
|
1391
|
+
}
|
|
1392
|
+
else if (source.dispatch_class === 'legacy-adoption'
|
|
1393
|
+
&& source.domain === 'entity-event'
|
|
1394
|
+
&& source.mutation_kind === 'adopt-legacy-batch') {
|
|
1395
|
+
this.readEntityEventLegacyAdoptionRecoveryIntent(workspaceId, source);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const latestAttachmentSourceByEntity = new Map();
|
|
1399
|
+
for (const source of recoverySources) {
|
|
1400
|
+
if (source.domain === 'task-attachment-links' && source.mutation_kind === 'replace') {
|
|
1401
|
+
latestAttachmentSourceByEntity.set(requireText(source.entity_id, 'Source entity id'), source);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const sources = recoverySources.filter((source) => {
|
|
1405
|
+
if (source.domain === 'task-comments' && source.mutation_kind === 'append') {
|
|
1406
|
+
return !this.isTaskCommentAppendRecoveryIntentSatisfied(tenantId, workspaceId, source);
|
|
1407
|
+
}
|
|
1408
|
+
if (source.domain === 'task' && source.mutation_kind === 'create') {
|
|
1409
|
+
this.assertTaskCreateRecoveryIntentSatisfied(tenantId, workspaceId, source);
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
if (source.domain === 'task-attachment-links'
|
|
1413
|
+
&& source.mutation_kind === 'replace') {
|
|
1414
|
+
if (latestAttachmentSourceByEntity.get(requireText(source.entity_id, 'Source entity id')) !== source) {
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
return !this.isTaskAttachmentLinksRecoveryIntentSatisfied(tenantId, workspaceId, source);
|
|
1418
|
+
}
|
|
1419
|
+
if (source.dispatch_class === 'legacy-adoption'
|
|
1420
|
+
&& source.domain === 'entity-event'
|
|
1421
|
+
&& source.mutation_kind === 'adopt-legacy-batch') {
|
|
1422
|
+
// Legacy adoption migrates immutable historical activity; it is
|
|
1423
|
+
// not a user-authored mutation that can be rebased. A targeted
|
|
1424
|
+
// authoritative baseline proves that every event identity exists
|
|
1425
|
+
// in cloud and installs the canonical cloud body locally. Trying
|
|
1426
|
+
// to replay the older frozen body can only re-create the same
|
|
1427
|
+
// identity/revision conflict forever, so the baseline itself
|
|
1428
|
+
// satisfies this migration intent.
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
if (Number(source.baseline_satisfaction_candidate || 0) !== 1) {
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
return !this.isTaskLifecycleRecoveryIntentSatisfied(tenantId, workspaceId, source);
|
|
1435
|
+
});
|
|
1436
|
+
if (sources.length === 0) {
|
|
1437
|
+
const completed = this.db.prepare(`
|
|
1438
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
1439
|
+
SET state = 'completed', completed_at = ?, updated_at = ?
|
|
1440
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1441
|
+
AND state = 'baseline_ready'
|
|
1442
|
+
`).run(now, now, tenantId, workspaceId, recoveryBatchId);
|
|
1443
|
+
if (Number(completed.changes || 0) !== 1) {
|
|
1444
|
+
throw new Error('Workspace sync recovery phase changed while recording baseline satisfaction.');
|
|
1445
|
+
}
|
|
1446
|
+
return { recoveryBatchId, operationIds: [] };
|
|
1447
|
+
}
|
|
1448
|
+
const newerActive = this.db.prepare(`
|
|
1449
|
+
SELECT active.operation_id
|
|
1450
|
+
FROM workspace_sync_outbox AS active
|
|
1451
|
+
WHERE active.tenant_id = ? AND active.workspace_id = ?
|
|
1452
|
+
AND active.status IN ('pending', 'in_flight', 'blocked')
|
|
1453
|
+
AND COALESCE(active.recovery_batch_id, '') <> ?
|
|
1454
|
+
AND EXISTS (
|
|
1455
|
+
SELECT 1
|
|
1456
|
+
FROM workspace_sync_outbox_targets AS recovered_target
|
|
1457
|
+
INNER JOIN workspace_sync_outbox AS recovered
|
|
1458
|
+
ON recovered.tenant_id = recovered_target.tenant_id
|
|
1459
|
+
AND recovered.workspace_id = recovered_target.workspace_id
|
|
1460
|
+
AND recovered.client_id = recovered_target.client_id
|
|
1461
|
+
AND recovered.operation_id = recovered_target.operation_id
|
|
1462
|
+
INNER JOIN workspace_sync_outbox_targets AS active_target
|
|
1463
|
+
ON active_target.tenant_id = recovered_target.tenant_id
|
|
1464
|
+
AND active_target.workspace_id = recovered_target.workspace_id
|
|
1465
|
+
AND active_target.domain = recovered_target.domain
|
|
1466
|
+
AND active_target.entity_id = recovered_target.entity_id
|
|
1467
|
+
WHERE recovered.tenant_id = active.tenant_id
|
|
1468
|
+
AND recovered.workspace_id = active.workspace_id
|
|
1469
|
+
AND recovered.recovery_batch_id = ?
|
|
1470
|
+
AND recovered.status = 'superseded'
|
|
1471
|
+
AND active_target.client_id = active.client_id
|
|
1472
|
+
AND active_target.operation_id = active.operation_id
|
|
1473
|
+
)
|
|
1474
|
+
LIMIT 1
|
|
1475
|
+
`).get(tenantId, workspaceId, recoveryBatchId, recoveryBatchId);
|
|
1476
|
+
if (newerActive) {
|
|
1477
|
+
throw new Error('Workspace sync recovery has newer active intent that requires semantic reconciliation.');
|
|
1478
|
+
}
|
|
1479
|
+
const replayPayloads = new Map();
|
|
1480
|
+
for (const source of sources) {
|
|
1481
|
+
const supportedTaskRoot = source.domain === 'task'
|
|
1482
|
+
&& TASK_ROOT_SUCCESSOR_MUTATIONS.has(String(source.mutation_kind || ''));
|
|
1483
|
+
const supportedTaskComment = source.domain === 'task-comments'
|
|
1484
|
+
&& source.mutation_kind === 'append';
|
|
1485
|
+
const supportedTaskAttachmentLinks = source.domain === 'task-attachment-links'
|
|
1486
|
+
&& source.mutation_kind === 'replace';
|
|
1487
|
+
const supportedEntityEventAdoption = source.dispatch_class === 'legacy-adoption'
|
|
1488
|
+
&& source.domain === 'entity-event'
|
|
1489
|
+
&& source.mutation_kind === 'adopt-legacy-batch';
|
|
1490
|
+
if (!supportedTaskRoot && !supportedTaskComment && !supportedTaskAttachmentLinks
|
|
1491
|
+
&& !supportedEntityEventAdoption) {
|
|
1492
|
+
throw new Error('Workspace sync recovery replay currently supports task-root, task-comment append, '
|
|
1493
|
+
+ 'task attachment-link, and legacy entity-event adoption intents only.');
|
|
1494
|
+
}
|
|
1495
|
+
replayPayloads.set(`${requireText(source.client_id, 'Source client id')}\0${requireText(source.operation_id, 'Source operation id')}`, this.prepareRecoveryPayload(tenantId, workspaceId, source));
|
|
1496
|
+
}
|
|
1497
|
+
for (const source of sources) {
|
|
1498
|
+
const sourceClientId = requireText(source.client_id, 'Source client id');
|
|
1499
|
+
const sourceOperationId = requireText(source.operation_id, 'Source operation id');
|
|
1500
|
+
const operationId = source.dispatch_class === 'legacy-adoption'
|
|
1501
|
+
&& source.domain === 'entity-event'
|
|
1502
|
+
&& source.mutation_kind === 'adopt-legacy-batch'
|
|
1503
|
+
? buildEntityEventAdoptionRecoveryReplayOperationId(recoveryBatchId, baselineMarker, sourceClientId, sourceOperationId)
|
|
1504
|
+
: buildRecoveryReplayOperationId(recoveryBatchId, baselineMarker, sourceClientId, sourceOperationId);
|
|
1505
|
+
const replayPayload = replayPayloads.get(`${sourceClientId}\0${sourceOperationId}`);
|
|
1506
|
+
if (!replayPayload)
|
|
1507
|
+
throw new Error('Workspace sync recovery replay payload is missing.');
|
|
1508
|
+
const sourceTargets = this.db.prepare(`
|
|
1509
|
+
SELECT domain, entity_id, mutation_kind,
|
|
1510
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee
|
|
1511
|
+
FROM workspace_sync_outbox_targets
|
|
1512
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1513
|
+
ORDER BY domain, entity_id
|
|
1514
|
+
`).all(tenantId, workspaceId, sourceClientId, sourceOperationId);
|
|
1515
|
+
const replayTargets = source.domain === 'task-comments'
|
|
1516
|
+
&& source.mutation_kind === 'append'
|
|
1517
|
+
? sourceTargets.filter((target) => (target.domain === 'task-comments'
|
|
1518
|
+
&& target.entity_id === source.entity_id))
|
|
1519
|
+
: sourceTargets;
|
|
1520
|
+
if (replayTargets.length === 0)
|
|
1521
|
+
throw new Error('Workspace sync recovery source has no targets.');
|
|
1522
|
+
const targetRevisions = replayTargets.map((target) => ({
|
|
1523
|
+
target,
|
|
1524
|
+
revision: this.readEntityRevisionCoordinates({
|
|
1525
|
+
tenantId,
|
|
1526
|
+
workspaceId,
|
|
1527
|
+
domain: requireText(target.domain, 'Target domain'),
|
|
1528
|
+
entityId: requireText(target.entity_id, 'Target entity id'),
|
|
1529
|
+
}),
|
|
1530
|
+
}));
|
|
1531
|
+
const primary = targetRevisions.find(({ target }) => (target.domain === source.domain && target.entity_id === source.entity_id));
|
|
1532
|
+
if (!primary)
|
|
1533
|
+
throw new Error('Workspace sync recovery source primary target is missing.');
|
|
1534
|
+
const enqueueSequence = this.allocateOutboxEnqueueSequence(tenantId, workspaceId, now);
|
|
1535
|
+
this.db.prepare(`
|
|
1536
|
+
INSERT INTO workspace_sync_outbox (
|
|
1537
|
+
tenant_id, workspace_id, operation_id, contract_version, client_id,
|
|
1538
|
+
dispatch_class, domain, entity_id, mutation_kind,
|
|
1539
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
1540
|
+
base_entity_revision, base_lifecycle_revision,
|
|
1541
|
+
payload_json, payload_fingerprint, status, attempt_count,
|
|
1542
|
+
initial_not_found_attempt_count, enqueue_sequence, rebase_before_first_attempt,
|
|
1543
|
+
next_attempt_at, recovery_batch_id,
|
|
1544
|
+
replay_of_client_id, replay_of_operation_id, created_at, updated_at
|
|
1545
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, 0, ?, 1,
|
|
1546
|
+
?, ?, ?, ?, ?, ?)
|
|
1547
|
+
ON CONFLICT (tenant_id, workspace_id, client_id, operation_id) DO NOTHING
|
|
1548
|
+
`).run(tenantId, workspaceId, operationId, WORKSPACE_SYNC_CONTRACT_VERSION, sourceClientId, requireOutboxDispatchClass(source.dispatch_class), requireText(source.domain, 'Source domain'), requireText(source.entity_id, 'Source entity id'), replayPayload.replayMutationKind, Number(source.owns_task_metadata) === 1 ? 1 : 0, Number(source.owns_task_lifecycle) === 1 ? 1 : 0, Number(source.owns_task_assignee) === 1 ? 1 : 0, primary.revision.entityRevision, primary.revision.lifecycleRevision, replayPayload.payloadJson, replayPayload.payloadFingerprint, enqueueSequence, now, recoveryBatchId, sourceClientId, sourceOperationId, now, now);
|
|
1549
|
+
this.db.prepare(`
|
|
1550
|
+
UPDATE workspace_sync_outbox
|
|
1551
|
+
SET base_entity_revision = ?, base_lifecycle_revision = ?,
|
|
1552
|
+
payload_json = ?, payload_fingerprint = ?,
|
|
1553
|
+
status = 'pending', attempt_count = 0, initial_not_found_attempt_count = 0,
|
|
1554
|
+
rebase_before_first_attempt = 1, next_attempt_at = ?,
|
|
1555
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
1556
|
+
authoritative_response_json = NULL, accepted_sequence = NULL,
|
|
1557
|
+
last_error_code = NULL, last_error_message = NULL,
|
|
1558
|
+
superseded_at = NULL, updated_at = ?
|
|
1559
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1560
|
+
AND recovery_batch_id = ? AND replay_of_client_id = ? AND replay_of_operation_id = ?
|
|
1561
|
+
AND status = 'superseded'
|
|
1562
|
+
`).run(primary.revision.entityRevision, primary.revision.lifecycleRevision, replayPayload.payloadJson, replayPayload.payloadFingerprint, now, now, tenantId, workspaceId, sourceClientId, operationId, recoveryBatchId, sourceClientId, sourceOperationId);
|
|
1563
|
+
if (replayPayload.workflowDependencyCoalesced) {
|
|
1564
|
+
this.db.prepare(`
|
|
1565
|
+
UPDATE workspace_sync_outbox
|
|
1566
|
+
SET last_error_code = 'WORKSPACE_SYNC_WORKFLOW_DEPENDENCY_COALESCED',
|
|
1567
|
+
last_error_message = 'Replayed after an earlier accepted operation satisfied the exact workflow activation dependency.',
|
|
1568
|
+
updated_at = ?
|
|
1569
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1570
|
+
`).run(now, tenantId, workspaceId, sourceClientId, operationId);
|
|
1571
|
+
}
|
|
1572
|
+
const replayRow = this.db.prepare(`
|
|
1573
|
+
SELECT domain, entity_id, mutation_kind, payload_fingerprint,
|
|
1574
|
+
recovery_batch_id, replay_of_client_id, replay_of_operation_id
|
|
1575
|
+
FROM workspace_sync_outbox
|
|
1576
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1577
|
+
`).get(tenantId, workspaceId, sourceClientId, operationId);
|
|
1578
|
+
if (!replayRow
|
|
1579
|
+
|| replayRow.domain !== source.domain
|
|
1580
|
+
|| replayRow.entity_id !== source.entity_id
|
|
1581
|
+
|| replayRow.mutation_kind !== replayPayload.replayMutationKind
|
|
1582
|
+
|| replayRow.payload_fingerprint !== replayPayload.payloadFingerprint
|
|
1583
|
+
|| replayRow.recovery_batch_id !== recoveryBatchId
|
|
1584
|
+
|| replayRow.replay_of_client_id !== sourceClientId
|
|
1585
|
+
|| replayRow.replay_of_operation_id !== sourceOperationId) {
|
|
1586
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
1587
|
+
}
|
|
1588
|
+
for (const { target, revision } of targetRevisions) {
|
|
1589
|
+
this.db.prepare(`
|
|
1590
|
+
INSERT INTO workspace_sync_outbox_targets (
|
|
1591
|
+
tenant_id, workspace_id, client_id, operation_id,
|
|
1592
|
+
domain, entity_id, mutation_kind,
|
|
1593
|
+
base_entity_revision, base_lifecycle_revision,
|
|
1594
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee, created_at
|
|
1595
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1596
|
+
ON CONFLICT (tenant_id, workspace_id, client_id, operation_id, domain, entity_id)
|
|
1597
|
+
DO NOTHING
|
|
1598
|
+
`).run(tenantId, workspaceId, sourceClientId, operationId, requireText(target.domain, 'Target domain'), requireText(target.entity_id, 'Target entity id'), target.domain === source.domain && target.entity_id === source.entity_id
|
|
1599
|
+
? replayPayload.replayMutationKind
|
|
1600
|
+
: requireText(target.mutation_kind, 'Target mutation kind'), revision.entityRevision, revision.lifecycleRevision, Number(target.owns_task_metadata) === 1 ? 1 : 0, Number(target.owns_task_lifecycle) === 1 ? 1 : 0, Number(target.owns_task_assignee) === 1 ? 1 : 0, now);
|
|
1601
|
+
this.db.prepare(`
|
|
1602
|
+
UPDATE workspace_sync_outbox_targets
|
|
1603
|
+
SET base_entity_revision = ?, base_lifecycle_revision = ?
|
|
1604
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1605
|
+
AND domain = ? AND entity_id = ?
|
|
1606
|
+
`).run(revision.entityRevision, revision.lifecycleRevision, tenantId, workspaceId, sourceClientId, operationId, requireText(target.domain, 'Target domain'), requireText(target.entity_id, 'Target entity id'));
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const updated = this.db.prepare(`
|
|
1610
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
1611
|
+
SET state = 'replay_queued', replay_queued_at = ?, updated_at = ?
|
|
1612
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1613
|
+
AND state = 'baseline_ready'
|
|
1614
|
+
`).run(now, now, tenantId, workspaceId, recoveryBatchId);
|
|
1615
|
+
if (Number(updated.changes || 0) !== 1) {
|
|
1616
|
+
throw new Error('Workspace sync recovery phase changed while preparing replay.');
|
|
1617
|
+
}
|
|
1618
|
+
return { recoveryBatchId, operationIds: listReplayOperationIds() };
|
|
1619
|
+
})();
|
|
1620
|
+
}
|
|
1621
|
+
readOpenOutboxRecovery(input) {
|
|
1622
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1623
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1624
|
+
const row = this.db.prepare(`
|
|
1625
|
+
SELECT recovery_batch_id, root_client_id, root_operation_id, state,
|
|
1626
|
+
baseline_marker, created_at, updated_at, completed_at
|
|
1627
|
+
FROM workspace_sync_outbox_recoveries
|
|
1628
|
+
WHERE tenant_id = ? AND workspace_id = ? AND state <> 'completed'
|
|
1629
|
+
ORDER BY created_at, recovery_batch_id
|
|
1630
|
+
LIMIT 1
|
|
1631
|
+
`).get(tenantId, workspaceId);
|
|
1632
|
+
return row ? this.mapOutboxRecovery(tenantId, workspaceId, row) : null;
|
|
1633
|
+
}
|
|
1634
|
+
readOutboxRecovery(input) {
|
|
1635
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1636
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1637
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1638
|
+
const row = this.db.prepare(`
|
|
1639
|
+
SELECT recovery_batch_id, root_client_id, root_operation_id, state,
|
|
1640
|
+
baseline_marker, created_at, updated_at, completed_at
|
|
1641
|
+
FROM workspace_sync_outbox_recoveries
|
|
1642
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1643
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
1644
|
+
return row ? this.mapOutboxRecovery(tenantId, workspaceId, row) : null;
|
|
1645
|
+
}
|
|
1646
|
+
findOldestBlockedOutboxIdentity(input) {
|
|
1647
|
+
return this.listBlockedOutboxIdentities(input)[0] || null;
|
|
1648
|
+
}
|
|
1649
|
+
listBlockedOutboxIdentities(input) {
|
|
1650
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1651
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1652
|
+
const rows = this.db.prepare(`
|
|
1653
|
+
SELECT client_id, operation_id
|
|
1654
|
+
FROM workspace_sync_outbox
|
|
1655
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
1656
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
1657
|
+
`).all(tenantId, workspaceId);
|
|
1658
|
+
return rows.map((row) => ({
|
|
1659
|
+
tenantId,
|
|
1660
|
+
workspaceId,
|
|
1661
|
+
clientId: requireText(row.client_id, 'Client id'),
|
|
1662
|
+
operationId: requireText(row.operation_id, 'Operation id'),
|
|
1663
|
+
}));
|
|
1664
|
+
}
|
|
1665
|
+
restartOutboxRecoveryAfterRebaseConflict(input) {
|
|
1666
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1667
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1668
|
+
const recoveryBatchId = requireText(input.recoveryBatchId, 'Recovery batch id');
|
|
1669
|
+
const now = requireIsoTimestamp(input.now, 'Recovery restart time');
|
|
1670
|
+
return this.db.transaction(() => {
|
|
1671
|
+
const recovery = this.db.prepare(`
|
|
1672
|
+
SELECT state
|
|
1673
|
+
FROM workspace_sync_outbox_recoveries
|
|
1674
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1675
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1676
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
1677
|
+
if (!recovery || recovery.state !== 'replay_queued')
|
|
1678
|
+
return false;
|
|
1679
|
+
const counts = this.db.prepare(`
|
|
1680
|
+
SELECT
|
|
1681
|
+
COUNT(*) AS total,
|
|
1682
|
+
SUM(CASE WHEN status = 'accepted' THEN 1 ELSE 0 END) AS accepted,
|
|
1683
|
+
SUM(CASE WHEN status = 'in_flight' THEN 1 ELSE 0 END) AS in_flight,
|
|
1684
|
+
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked,
|
|
1685
|
+
SUM(CASE
|
|
1686
|
+
WHEN status = 'blocked' AND last_error_code IN (
|
|
1687
|
+
'WORKSPACE_SYNC_REVISION_CONFLICT',
|
|
1688
|
+
'WORKSPACE_SYNC_IDEMPOTENCY_CONFLICT'
|
|
1689
|
+
)
|
|
1690
|
+
THEN 1 ELSE 0 END
|
|
1691
|
+
) AS rebase_conflicts
|
|
1692
|
+
FROM workspace_sync_outbox
|
|
1693
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1694
|
+
AND replay_of_operation_id IS NOT NULL
|
|
1695
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
1696
|
+
if (Number(counts.total || 0) === 0
|
|
1697
|
+
|| Number(counts.accepted || 0) !== 0
|
|
1698
|
+
|| Number(counts.in_flight || 0) !== 0) {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
const lifecycleConflicts = this.db.prepare(`
|
|
1702
|
+
SELECT source.entity_id, source.mutation_kind, source.payload_json,
|
|
1703
|
+
replay.last_error_code, replay.last_error_message
|
|
1704
|
+
FROM workspace_sync_outbox AS replay
|
|
1705
|
+
INNER JOIN workspace_sync_outbox AS source
|
|
1706
|
+
ON source.tenant_id = replay.tenant_id
|
|
1707
|
+
AND source.workspace_id = replay.workspace_id
|
|
1708
|
+
AND source.client_id = replay.replay_of_client_id
|
|
1709
|
+
AND source.operation_id = replay.replay_of_operation_id
|
|
1710
|
+
WHERE replay.tenant_id = ? AND replay.workspace_id = ?
|
|
1711
|
+
AND replay.recovery_batch_id = ?
|
|
1712
|
+
AND replay.status = 'blocked'
|
|
1713
|
+
AND replay.last_error_code IN (
|
|
1714
|
+
'TASK_LIFECYCLE_ALREADY_ARCHIVED',
|
|
1715
|
+
'TASK_LIFECYCLE_NOT_ARCHIVED',
|
|
1716
|
+
'TASK_LIFECYCLE_STATE_CONFLICT'
|
|
1717
|
+
)
|
|
1718
|
+
`).all(tenantId, workspaceId, recoveryBatchId);
|
|
1719
|
+
const baselineSatisfied = lifecycleConflicts.filter((row) => (this.isTargetStateLifecycleConflict(row)
|
|
1720
|
+
&& this.isTaskLifecycleRecoveryIntentSatisfied(tenantId, workspaceId, row)));
|
|
1721
|
+
if (Number(counts.rebase_conflicts || 0) === 0 && baselineSatisfied.length === 0) {
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
if (Number(counts.blocked || 0)
|
|
1725
|
+
!== Number(counts.rebase_conflicts || 0) + baselineSatisfied.length) {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
this.db.prepare(`
|
|
1729
|
+
UPDATE workspace_sync_outbox
|
|
1730
|
+
SET status = 'superseded',
|
|
1731
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
1732
|
+
superseded_at = ?, updated_at = ?
|
|
1733
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1734
|
+
AND replay_of_operation_id IS NOT NULL
|
|
1735
|
+
AND status IN ('pending', 'blocked')
|
|
1736
|
+
`).run(now, now, tenantId, workspaceId, recoveryBatchId);
|
|
1737
|
+
const restarted = this.db.prepare(`
|
|
1738
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
1739
|
+
SET state = 'awaiting_baseline',
|
|
1740
|
+
baseline_marker = NULL, baseline_applied_at = NULL,
|
|
1741
|
+
replay_queued_at = NULL, updated_at = ?
|
|
1742
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1743
|
+
AND state = 'replay_queued'
|
|
1744
|
+
`).run(now, tenantId, workspaceId, recoveryBatchId);
|
|
1745
|
+
if (Number(restarted.changes || 0) !== 1) {
|
|
1746
|
+
throw new Error('Workspace sync recovery phase changed while restarting authoritative repair.');
|
|
1747
|
+
}
|
|
1748
|
+
return true;
|
|
1749
|
+
})();
|
|
1750
|
+
}
|
|
1751
|
+
completeOutboxRecoveryIfSettled(input) {
|
|
1752
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1753
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1754
|
+
const now = requireIsoTimestamp(input.now, 'Recovery completion time');
|
|
1755
|
+
return this.db.transaction(() => {
|
|
1756
|
+
let recovery = this.readOpenOutboxRecovery({ tenantId, workspaceId });
|
|
1757
|
+
let settledBaselineSatisfiedLegacyReplays = 0;
|
|
1758
|
+
if (recovery?.state === 'replay_queued' && recovery.baselineMarker) {
|
|
1759
|
+
const baselineSatisfiedLegacyReplays = this.db.prepare(`
|
|
1760
|
+
SELECT replay.client_id, replay.operation_id,
|
|
1761
|
+
source.dispatch_class, source.domain, source.entity_id,
|
|
1762
|
+
source.mutation_kind, source.payload_json, source.payload_fingerprint
|
|
1763
|
+
FROM workspace_sync_outbox AS replay
|
|
1764
|
+
INNER JOIN workspace_sync_outbox AS source
|
|
1765
|
+
ON source.tenant_id = replay.tenant_id
|
|
1766
|
+
AND source.workspace_id = replay.workspace_id
|
|
1767
|
+
AND source.client_id = replay.replay_of_client_id
|
|
1768
|
+
AND source.operation_id = replay.replay_of_operation_id
|
|
1769
|
+
WHERE replay.tenant_id = ? AND replay.workspace_id = ?
|
|
1770
|
+
AND replay.recovery_batch_id = ?
|
|
1771
|
+
AND replay.status = 'blocked'
|
|
1772
|
+
AND replay.dispatch_class = 'legacy-adoption'
|
|
1773
|
+
AND replay.domain = 'entity-event'
|
|
1774
|
+
AND replay.mutation_kind = 'adopt-legacy-batch'
|
|
1775
|
+
AND replay.last_error_code IN (
|
|
1776
|
+
'WORKSPACE_SYNC_REVISION_CONFLICT',
|
|
1777
|
+
'WORKSPACE_SYNC_IDEMPOTENCY_CONFLICT'
|
|
1778
|
+
)
|
|
1779
|
+
AND source.dispatch_class = 'legacy-adoption'
|
|
1780
|
+
AND source.domain = 'entity-event'
|
|
1781
|
+
AND source.mutation_kind = 'adopt-legacy-batch'
|
|
1782
|
+
AND source.recovery_batch_id = replay.recovery_batch_id
|
|
1783
|
+
AND source.status = 'superseded'
|
|
1784
|
+
AND source.replay_of_operation_id IS NULL
|
|
1785
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE OF replay' : ''}
|
|
1786
|
+
`).all(tenantId, workspaceId, recovery.recoveryBatchId);
|
|
1787
|
+
for (const replay of baselineSatisfiedLegacyReplays) {
|
|
1788
|
+
// Older builds replayed immutable legacy adoption after applying
|
|
1789
|
+
// the authoritative baseline. Validate the frozen source before
|
|
1790
|
+
// settling that stranded replay; all mutable/user-authored intents
|
|
1791
|
+
// continue through the ordinary rebase path.
|
|
1792
|
+
if (!isLegacyEntityEventAdoptionIdentity({
|
|
1793
|
+
clientId: requireText(replay.client_id, 'Replay client id'),
|
|
1794
|
+
operationId: requireText(replay.operation_id, 'Replay operation id'),
|
|
1795
|
+
})) {
|
|
1796
|
+
throw new Error('Legacy entity-event adoption recovery replay identity is invalid.');
|
|
1797
|
+
}
|
|
1798
|
+
this.readEntityEventLegacyAdoptionRecoveryIntent(workspaceId, replay);
|
|
1799
|
+
const settled = this.db.prepare(`
|
|
1800
|
+
UPDATE workspace_sync_outbox
|
|
1801
|
+
SET status = 'superseded', superseded_at = ?, updated_at = ?,
|
|
1802
|
+
lease_owner = NULL, lease_expires_at = NULL
|
|
1803
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
1804
|
+
AND client_id = ? AND operation_id = ?
|
|
1805
|
+
AND recovery_batch_id = ? AND status = 'blocked'
|
|
1806
|
+
`).run(now, now, tenantId, workspaceId, requireText(replay.client_id, 'Replay client id'), requireText(replay.operation_id, 'Replay operation id'), recovery.recoveryBatchId);
|
|
1807
|
+
if (Number(settled.changes || 0) !== 1) {
|
|
1808
|
+
throw new Error('Legacy entity-event adoption recovery replay changed while settling.');
|
|
1809
|
+
}
|
|
1810
|
+
settledBaselineSatisfiedLegacyReplays += 1;
|
|
1811
|
+
}
|
|
1812
|
+
if (baselineSatisfiedLegacyReplays.length > 0) {
|
|
1813
|
+
recovery = this.readOpenOutboxRecovery({ tenantId, workspaceId });
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (!recovery || recovery.state !== 'replay_queued'
|
|
1817
|
+
|| (recovery.replayTotal === 0 && settledBaselineSatisfiedLegacyReplays === 0)
|
|
1818
|
+
|| recovery.replayAccepted !== recovery.replayTotal) {
|
|
1819
|
+
return recovery;
|
|
1820
|
+
}
|
|
1821
|
+
const updated = this.db.prepare(`
|
|
1822
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
1823
|
+
SET state = 'completed', completed_at = ?, updated_at = ?
|
|
1824
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1825
|
+
AND state = 'replay_queued'
|
|
1826
|
+
`).run(now, now, tenantId, workspaceId, recovery.recoveryBatchId);
|
|
1827
|
+
if (Number(updated.changes || 0) !== 1) {
|
|
1828
|
+
throw new Error('Workspace sync recovery phase changed while recording completion.');
|
|
1829
|
+
}
|
|
1830
|
+
return this.readOutboxRecovery({ tenantId, workspaceId, recoveryBatchId: recovery.recoveryBatchId });
|
|
1831
|
+
})();
|
|
1832
|
+
}
|
|
1833
|
+
mapOutboxRecovery(tenantId, workspaceId, row) {
|
|
1834
|
+
const recoveryBatchId = requireText(row.recovery_batch_id, 'Recovery batch id');
|
|
1835
|
+
const counts = this.db.prepare(`
|
|
1836
|
+
SELECT status, COUNT(*) AS count
|
|
1837
|
+
FROM workspace_sync_outbox
|
|
1838
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
1839
|
+
AND replay_of_operation_id IS NOT NULL
|
|
1840
|
+
GROUP BY status
|
|
1841
|
+
`).all(tenantId, workspaceId, recoveryBatchId);
|
|
1842
|
+
const byStatus = new Map(counts.map((entry) => [String(entry.status || ''), Number(entry.count || 0)]));
|
|
1843
|
+
const replayPending = byStatus.get('pending') || 0;
|
|
1844
|
+
const replayInFlight = byStatus.get('in_flight') || 0;
|
|
1845
|
+
const replayBlocked = byStatus.get('blocked') || 0;
|
|
1846
|
+
const replayAccepted = byStatus.get('accepted') || 0;
|
|
1847
|
+
const replayTotal = replayPending + replayInFlight + replayBlocked + replayAccepted;
|
|
1848
|
+
const state = String(row.state || '');
|
|
1849
|
+
if (!['awaiting_baseline', 'baseline_ready', 'replay_queued', 'completed'].includes(state)) {
|
|
1850
|
+
throw new Error('Workspace sync recovery state is invalid.');
|
|
1851
|
+
}
|
|
1852
|
+
return {
|
|
1853
|
+
recoveryBatchId,
|
|
1854
|
+
rootClientId: requireText(row.root_client_id, 'Recovery root client id'),
|
|
1855
|
+
rootOperationId: requireText(row.root_operation_id, 'Recovery root operation id'),
|
|
1856
|
+
state,
|
|
1857
|
+
baselineMarker: typeof row.baseline_marker === 'string' ? row.baseline_marker : null,
|
|
1858
|
+
replayTotal,
|
|
1859
|
+
replayPending,
|
|
1860
|
+
replayInFlight,
|
|
1861
|
+
replayBlocked,
|
|
1862
|
+
replayAccepted,
|
|
1863
|
+
createdAt: requireIsoTimestamp(row.created_at, 'Recovery creation time'),
|
|
1864
|
+
updatedAt: requireIsoTimestamp(row.updated_at, 'Recovery update time'),
|
|
1865
|
+
completedAt: row.completed_at == null ? null : requireIsoTimestamp(row.completed_at, 'Recovery completion time'),
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
readOperationStateForUpdate(identity) {
|
|
1869
|
+
const row = this.db.prepare(`
|
|
1870
|
+
SELECT status, response_json, created_at
|
|
1871
|
+
FROM workspace_sync_operation_results
|
|
1872
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
1873
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
1874
|
+
`).get(...operationParams(identity));
|
|
1875
|
+
if (!row)
|
|
1876
|
+
return null;
|
|
1877
|
+
if (row.status !== 'processing' && row.status !== 'committed') {
|
|
1878
|
+
throw new Error(`Unsupported operation status: ${String(row.status || '')}`);
|
|
1879
|
+
}
|
|
1880
|
+
return {
|
|
1881
|
+
status: row.status,
|
|
1882
|
+
responseJson: typeof row.response_json === 'string' ? row.response_json : null,
|
|
1883
|
+
createdAt: requireIsoTimestamp(row.created_at, 'Operation creation time'),
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
readCreateDependencyState(input) {
|
|
1887
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1888
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1889
|
+
const dependencies = Array.from(new Map(input.dependencies.map((dependency) => {
|
|
1890
|
+
const domain = requireText(dependency.domain, 'Dependency domain');
|
|
1891
|
+
const entityId = requireText(dependency.entityId, 'Dependency entity id');
|
|
1892
|
+
return [`${domain}\0${entityId}`, { domain, entityId }];
|
|
1893
|
+
})).values());
|
|
1894
|
+
if (dependencies.length === 0)
|
|
1895
|
+
return null;
|
|
1896
|
+
const clauses = dependencies.map(() => '(domain = ? AND entity_id = ?)').join(' OR ');
|
|
1897
|
+
const rows = this.db.prepare(`
|
|
1898
|
+
SELECT status
|
|
1899
|
+
FROM workspace_sync_outbox
|
|
1900
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
1901
|
+
AND contract_version = ? AND mutation_kind = 'create'
|
|
1902
|
+
AND (${clauses})
|
|
1903
|
+
AND status IN ('pending', 'in_flight', 'blocked')
|
|
1904
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, ...dependencies.flatMap((dependency) => [dependency.domain, dependency.entityId]));
|
|
1905
|
+
if (rows.some((row) => row.status === 'blocked'))
|
|
1906
|
+
return 'blocked';
|
|
1907
|
+
return rows.length > 0 ? 'pending' : null;
|
|
1908
|
+
}
|
|
1909
|
+
readTaskCreateDependencyState(input) {
|
|
1910
|
+
return this.readCreateDependencyState({
|
|
1911
|
+
tenantId: input.tenantId,
|
|
1912
|
+
workspaceId: input.workspaceId,
|
|
1913
|
+
dependencies: input.taskIds.map((entityId) => ({ domain: 'task', entityId })),
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
listActiveTaskRelationshipOperationIds(input) {
|
|
1917
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1918
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1919
|
+
const rows = this.db.prepare(`
|
|
1920
|
+
SELECT DISTINCT entity_id
|
|
1921
|
+
FROM workspace_sync_outbox
|
|
1922
|
+
WHERE tenant_id = ? AND workspace_id = ? AND contract_version = ?
|
|
1923
|
+
AND domain = 'task-relationship' AND mutation_kind IN ('link', 'unlink')
|
|
1924
|
+
AND status IN ('pending', 'in_flight', 'blocked')
|
|
1925
|
+
ORDER BY entity_id ASC
|
|
1926
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
1927
|
+
return rows.map((row) => requireText(row.entity_id, 'Task relationship id'));
|
|
1928
|
+
}
|
|
1929
|
+
listAttachmentAssetIdsAwaitingContentPublication(input) {
|
|
1930
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1931
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1932
|
+
const rows = this.db.prepare(`
|
|
1933
|
+
SELECT domain, payload_json
|
|
1934
|
+
FROM workspace_sync_outbox
|
|
1935
|
+
WHERE tenant_id = ? AND workspace_id = ? AND contract_version = ?
|
|
1936
|
+
AND (
|
|
1937
|
+
(domain = 'task-attachment-links' AND mutation_kind = 'replace')
|
|
1938
|
+
OR (domain = 'task' AND mutation_kind = 'create')
|
|
1939
|
+
)
|
|
1940
|
+
AND status IN ('pending', 'in_flight')
|
|
1941
|
+
AND last_error_code = 'TASK_ATTACHMENT_ASSET_NOT_READY'
|
|
1942
|
+
ORDER BY enqueue_sequence
|
|
1943
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
1944
|
+
const assetIds = new Set();
|
|
1945
|
+
for (const row of rows) {
|
|
1946
|
+
let payload;
|
|
1947
|
+
try {
|
|
1948
|
+
payload = JSON.parse(requireText(row.payload_json, 'Attachment-link payload'));
|
|
1949
|
+
}
|
|
1950
|
+
catch {
|
|
1951
|
+
continue;
|
|
1952
|
+
}
|
|
1953
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1954
|
+
continue;
|
|
1955
|
+
const links = String(row.domain || '') === 'task'
|
|
1956
|
+
? payload.attachmentLinks
|
|
1957
|
+
: payload.links;
|
|
1958
|
+
if (!Array.isArray(links))
|
|
1959
|
+
continue;
|
|
1960
|
+
for (const link of links) {
|
|
1961
|
+
if (!link || typeof link !== 'object' || Array.isArray(link))
|
|
1962
|
+
continue;
|
|
1963
|
+
const assetId = String(link.assetId || '').trim();
|
|
1964
|
+
if (assetId)
|
|
1965
|
+
assetIds.add(assetId);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return Array.from(assetIds).sort((a, b) => a.localeCompare(b));
|
|
1969
|
+
}
|
|
1970
|
+
runTaskRelationshipCoexistenceTransaction(input) {
|
|
1971
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
1972
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
1973
|
+
const now = requireIsoTimestamp(input.now, 'Coexistence lock timestamp');
|
|
1974
|
+
return this.db.transaction(() => {
|
|
1975
|
+
this.db.prepare(`
|
|
1976
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
1977
|
+
VALUES (?, ?, 'task-relationship', ?)
|
|
1978
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
1979
|
+
`).run(tenantId, workspaceId, now);
|
|
1980
|
+
return input.apply(this.listActiveTaskRelationshipOperationIds({ tenantId, workspaceId }));
|
|
1981
|
+
})();
|
|
1982
|
+
}
|
|
1983
|
+
reserveOperation(input) {
|
|
1984
|
+
const ids = operationParams(input.identity);
|
|
1985
|
+
const requestFingerprint = requireText(input.requestFingerprint, 'Request fingerprint');
|
|
1986
|
+
const now = requireIsoTimestamp(input.now, 'Reservation time');
|
|
1987
|
+
const leaseExpiresAt = requireIsoTimestamp(input.leaseExpiresAt, 'Lease expiry');
|
|
1988
|
+
if (leaseExpiresAt <= now)
|
|
1989
|
+
throw new Error('Lease expiry must be after the reservation time.');
|
|
1990
|
+
const leaseOwner = requireText(input.leaseOwner || randomUUID(), 'Lease owner');
|
|
1991
|
+
return this.db.transaction(() => {
|
|
1992
|
+
this.db.prepare(`
|
|
1993
|
+
INSERT INTO workspace_sync_operation_results (
|
|
1994
|
+
tenant_id, workspace_id, client_id, operation_id, contract_version,
|
|
1995
|
+
request_fingerprint, status, lease_owner, lease_expires_at,
|
|
1996
|
+
created_at, updated_at
|
|
1997
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'processing', ?, ?, ?, ?)
|
|
1998
|
+
ON CONFLICT (tenant_id, workspace_id, client_id, operation_id) DO NOTHING
|
|
1999
|
+
`).run(...ids, WORKSPACE_SYNC_CONTRACT_VERSION, requestFingerprint, leaseOwner, leaseExpiresAt, now, now);
|
|
2000
|
+
const row = this.readOperationRow(ids);
|
|
2001
|
+
if (!row)
|
|
2002
|
+
throw new Error('Workspace sync operation reservation was not persisted.');
|
|
2003
|
+
this.assertMatchingOperation(row, requestFingerprint);
|
|
2004
|
+
if (row.status === 'committed') {
|
|
2005
|
+
if (typeof row.response_json !== 'string')
|
|
2006
|
+
throw new Error('Committed operation is missing its response.');
|
|
2007
|
+
return {
|
|
2008
|
+
state: 'committed',
|
|
2009
|
+
responseJson: row.response_json,
|
|
2010
|
+
acceptedSequence: row.accepted_sequence == null ? null : normalizeSequence(row.accepted_sequence),
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (row.lease_owner === leaseOwner)
|
|
2014
|
+
return { state: 'owned', leaseOwner };
|
|
2015
|
+
const existingExpiry = requireIsoTimestamp(row.lease_expires_at, 'Existing lease expiry');
|
|
2016
|
+
if (existingExpiry > now)
|
|
2017
|
+
return { state: 'in_progress', retryAt: existingExpiry };
|
|
2018
|
+
const takeover = this.db.prepare(`
|
|
2019
|
+
UPDATE workspace_sync_operation_results
|
|
2020
|
+
SET lease_owner = ?, lease_expires_at = ?, updated_at = ?
|
|
2021
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2022
|
+
AND status = 'processing' AND request_fingerprint = ?
|
|
2023
|
+
AND lease_expires_at <= ?
|
|
2024
|
+
`).run(leaseOwner, leaseExpiresAt, now, ...ids, requestFingerprint, now);
|
|
2025
|
+
if (Number(takeover.changes || 0) === 1)
|
|
2026
|
+
return { state: 'owned', leaseOwner };
|
|
2027
|
+
const current = this.readOperationRow(ids);
|
|
2028
|
+
this.assertMatchingOperation(current, requestFingerprint);
|
|
2029
|
+
return {
|
|
2030
|
+
state: 'in_progress',
|
|
2031
|
+
retryAt: requireIsoTimestamp(current?.lease_expires_at, 'Existing lease expiry'),
|
|
2032
|
+
};
|
|
2033
|
+
})();
|
|
2034
|
+
}
|
|
2035
|
+
// A retryable failure (dependency not ready, revision conflict, etc.) can
|
|
2036
|
+
// be thrown after reserveOperation succeeds but before the operation
|
|
2037
|
+
// commits. Without this, the abandoned reservation blocks every retry
|
|
2038
|
+
// with WORKSPACE_SYNC_OPERATION_IN_PROGRESS until its lease naturally
|
|
2039
|
+
// expires, even though nothing was committed. This never deletes the row,
|
|
2040
|
+
// forces acceptance, or rewrites the operation identity; it only makes
|
|
2041
|
+
// the exact same identity immediately eligible for reservation takeover.
|
|
2042
|
+
releaseFailedOperationReservation(input) {
|
|
2043
|
+
const ids = operationParams(input.identity);
|
|
2044
|
+
const requestFingerprint = requireText(input.requestFingerprint, 'Request fingerprint');
|
|
2045
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
2046
|
+
const now = requireIsoTimestamp(input.now, 'Release time');
|
|
2047
|
+
this.db.prepare(`
|
|
2048
|
+
UPDATE workspace_sync_operation_results
|
|
2049
|
+
SET lease_expires_at = ?, updated_at = ?
|
|
2050
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2051
|
+
AND status = 'processing' AND request_fingerprint = ? AND lease_owner = ?
|
|
2052
|
+
`).run(now, now, ...ids, requestFingerprint, leaseOwner);
|
|
2053
|
+
}
|
|
2054
|
+
commitReservedOperationResponse(input) {
|
|
2055
|
+
const ids = operationParams(input.identity);
|
|
2056
|
+
const requestFingerprint = requireText(input.requestFingerprint, 'Request fingerprint');
|
|
2057
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
2058
|
+
const now = requireIsoTimestamp(input.now, 'Commit time');
|
|
2059
|
+
const responseJson = canonicalWorkspaceSyncJson(input.response);
|
|
2060
|
+
try {
|
|
2061
|
+
return this.db.transaction(() => {
|
|
2062
|
+
const committed = this.db.prepare(`
|
|
2063
|
+
UPDATE workspace_sync_operation_results
|
|
2064
|
+
SET status = 'committed', response_json = ?, accepted_sequence = NULL,
|
|
2065
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
2066
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2067
|
+
AND contract_version = ? AND status = 'processing'
|
|
2068
|
+
AND request_fingerprint = ? AND lease_owner = ?
|
|
2069
|
+
`).run(responseJson, now, ...ids, WORKSPACE_SYNC_CONTRACT_VERSION, requestFingerprint, leaseOwner);
|
|
2070
|
+
if (Number(committed.changes || 0) !== 1)
|
|
2071
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
2072
|
+
return { responseJson };
|
|
2073
|
+
})();
|
|
2074
|
+
}
|
|
2075
|
+
catch (error) {
|
|
2076
|
+
if (!(error instanceof WorkspaceSyncLeaseLostError)) {
|
|
2077
|
+
this.releaseFailedOperationReservation({ identity: input.identity, requestFingerprint, leaseOwner, now });
|
|
2078
|
+
}
|
|
2079
|
+
throw error;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
acceptReservedOperation(input) {
|
|
2083
|
+
return this.acceptReservedOperationBatch({
|
|
2084
|
+
identity: input.identity,
|
|
2085
|
+
requestFingerprint: input.requestFingerprint,
|
|
2086
|
+
leaseOwner: input.leaseOwner,
|
|
2087
|
+
now: input.now,
|
|
2088
|
+
entries: [{
|
|
2089
|
+
domain: input.domain,
|
|
2090
|
+
entityId: input.entityId,
|
|
2091
|
+
mutationKind: input.mutationKind,
|
|
2092
|
+
revisionKind: input.revisionKind,
|
|
2093
|
+
contentSha256: input.contentSha256,
|
|
2094
|
+
sizeBytes: input.sizeBytes,
|
|
2095
|
+
baseEntityRevision: input.baseEntityRevision,
|
|
2096
|
+
baseLifecycleRevision: input.baseLifecycleRevision,
|
|
2097
|
+
buildCanonicalFingerprint: input.buildCanonicalFingerprint,
|
|
2098
|
+
metadataOwned: input.metadataOwned,
|
|
2099
|
+
reopenAssigneeOwned: input.reopenAssigneeOwned,
|
|
2100
|
+
buildAuthoritativePayload: (result, revision) => input.buildAuthoritativePayload(result, {
|
|
2101
|
+
sequence: revision.sequence,
|
|
2102
|
+
entityRevision: revision.entityRevision,
|
|
2103
|
+
lifecycleRevision: revision.lifecycleRevision,
|
|
2104
|
+
}),
|
|
2105
|
+
}],
|
|
2106
|
+
applyMutation: input.applyMutation,
|
|
2107
|
+
buildResponse: (result, revisions) => input.buildResponse(result, {
|
|
2108
|
+
sequence: revisions[0].sequence,
|
|
2109
|
+
entityRevision: revisions[0].entityRevision,
|
|
2110
|
+
lifecycleRevision: revisions[0].lifecycleRevision,
|
|
2111
|
+
}),
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
acceptReservedOperationBatch(input) {
|
|
2115
|
+
const ids = operationParams(input.identity);
|
|
2116
|
+
const requestFingerprint = requireText(input.requestFingerprint, 'Request fingerprint');
|
|
2117
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
2118
|
+
const now = requireIsoTimestamp(input.now, 'Acceptance time');
|
|
2119
|
+
try {
|
|
2120
|
+
if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > 100) {
|
|
2121
|
+
throw new Error('Workspace sync journal entry count must be between 1 and 100.');
|
|
2122
|
+
}
|
|
2123
|
+
const entries = input.entries.map((entry) => ({
|
|
2124
|
+
...entry,
|
|
2125
|
+
domain: requireText(entry.domain, 'Domain'),
|
|
2126
|
+
entityId: requireText(entry.entityId, 'Entity id'),
|
|
2127
|
+
mutationKind: requireText(entry.mutationKind, 'Mutation kind'),
|
|
2128
|
+
revisionKind: normalizeRevisionKind(entry.revisionKind),
|
|
2129
|
+
baseEntityRevision: normalizeSequence(entry.baseEntityRevision),
|
|
2130
|
+
baseLifecycleRevision: normalizeSequence(entry.baseLifecycleRevision),
|
|
2131
|
+
baseRevisionPolicy: entry.baseRevisionPolicy || 'exact',
|
|
2132
|
+
sizeBytes: entry.sizeBytes == null ? null : normalizeSequence(entry.sizeBytes),
|
|
2133
|
+
}));
|
|
2134
|
+
const targets = new Set();
|
|
2135
|
+
for (const entry of entries) {
|
|
2136
|
+
if (entry.baseRevisionPolicy !== 'exact' && entry.baseRevisionPolicy !== 'current') {
|
|
2137
|
+
throw new Error(`Unsupported workspace sync base revision policy: ${entry.baseRevisionPolicy}.`);
|
|
2138
|
+
}
|
|
2139
|
+
const target = `${entry.domain}\u0000${entry.entityId}`;
|
|
2140
|
+
if (targets.has(target))
|
|
2141
|
+
throw new Error(`Duplicate workspace sync journal target: ${entry.domain}/${entry.entityId}.`);
|
|
2142
|
+
targets.add(target);
|
|
2143
|
+
}
|
|
2144
|
+
return this.db.transaction(() => {
|
|
2145
|
+
const owned = this.db.prepare(`
|
|
2146
|
+
UPDATE workspace_sync_operation_results
|
|
2147
|
+
SET updated_at = ?
|
|
2148
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2149
|
+
AND contract_version = ? AND status = 'processing'
|
|
2150
|
+
AND request_fingerprint = ? AND lease_owner = ?
|
|
2151
|
+
`).run(now, ...ids, WORKSPACE_SYNC_CONTRACT_VERSION, requestFingerprint, leaseOwner);
|
|
2152
|
+
if (Number(owned.changes || 0) !== 1)
|
|
2153
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
2154
|
+
// A zero-width allocation locks the workspace sequence row without
|
|
2155
|
+
// consuming a sequence. This lets a mutation determine whether its
|
|
2156
|
+
// optional journal entries exist while preserving a gap-free range.
|
|
2157
|
+
this.allocateSequenceRange(ids[0], ids[1], now, 0);
|
|
2158
|
+
const buildPendingRevision = (entry) => {
|
|
2159
|
+
const current = this.db.prepare(`
|
|
2160
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2161
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision
|
|
2162
|
+
FROM workspace_sync_entity_revisions
|
|
2163
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
2164
|
+
`).get(ids[0], ids[1], entry.domain, entry.entityId);
|
|
2165
|
+
let entityRevision = BigInt(normalizeSequence(current?.entity_revision ?? 0));
|
|
2166
|
+
let lifecycleRevision = BigInt(normalizeSequence(current?.lifecycle_revision ?? 0));
|
|
2167
|
+
const baseEntityRevision = BigInt(entry.baseEntityRevision);
|
|
2168
|
+
const baseLifecycleRevision = BigInt(entry.baseLifecycleRevision);
|
|
2169
|
+
const invalidExact = entry.baseRevisionPolicy === 'exact'
|
|
2170
|
+
&& (entityRevision !== baseEntityRevision || lifecycleRevision !== baseLifecycleRevision);
|
|
2171
|
+
const invalidCurrent = entry.baseRevisionPolicy === 'current'
|
|
2172
|
+
&& (baseEntityRevision > entityRevision || baseLifecycleRevision > lifecycleRevision);
|
|
2173
|
+
if (invalidExact || invalidCurrent) {
|
|
2174
|
+
throw new WorkspaceSyncRevisionConflictError();
|
|
2175
|
+
}
|
|
2176
|
+
if (entry.revisionKind === 'metadata' || entry.revisionKind === 'both')
|
|
2177
|
+
entityRevision += 1n;
|
|
2178
|
+
if (entry.revisionKind === 'lifecycle' || entry.revisionKind === 'both')
|
|
2179
|
+
lifecycleRevision += 1n;
|
|
2180
|
+
// Earlier lifecycle-only writers could advance lifecycle past
|
|
2181
|
+
// entity revision. Preserve base-revision validation, then
|
|
2182
|
+
// repair the accepted coordinates before journaling new state.
|
|
2183
|
+
if (lifecycleRevision > entityRevision)
|
|
2184
|
+
entityRevision = lifecycleRevision;
|
|
2185
|
+
return {
|
|
2186
|
+
entityRevision: entityRevision.toString(10),
|
|
2187
|
+
lifecycleRevision: lifecycleRevision.toString(10),
|
|
2188
|
+
};
|
|
2189
|
+
};
|
|
2190
|
+
const requiredRevisions = new Map(entries
|
|
2191
|
+
.filter((entry) => !entry.include)
|
|
2192
|
+
.map((entry) => [`${entry.domain}\u0000${entry.entityId}`, buildPendingRevision(entry)]));
|
|
2193
|
+
const mutationResult = input.applyMutation();
|
|
2194
|
+
const activeEntries = entries.filter((entry) => entry.include?.(mutationResult) !== false);
|
|
2195
|
+
if (activeEntries.length === 0) {
|
|
2196
|
+
throw new Error('Workspace sync operation must produce at least one journal entry.');
|
|
2197
|
+
}
|
|
2198
|
+
const acceptedSequence = this.allocateSequenceRange(ids[0], ids[1], now, activeEntries.length);
|
|
2199
|
+
const firstSequence = BigInt(acceptedSequence) - BigInt(activeEntries.length) + 1n;
|
|
2200
|
+
const pendingRevisions = activeEntries.map((entry) => (requiredRevisions.get(`${entry.domain}\u0000${entry.entityId}`) || buildPendingRevision(entry)));
|
|
2201
|
+
const revisions = pendingRevisions.map((revision, index) => ({
|
|
2202
|
+
domain: activeEntries[index].domain,
|
|
2203
|
+
entityId: activeEntries[index].entityId,
|
|
2204
|
+
sequence: (firstSequence + BigInt(index)).toString(10),
|
|
2205
|
+
entityRevision: revision.entityRevision,
|
|
2206
|
+
lifecycleRevision: revision.lifecycleRevision,
|
|
2207
|
+
}));
|
|
2208
|
+
activeEntries.forEach((entry, index) => {
|
|
2209
|
+
const revision = revisions[index];
|
|
2210
|
+
const canonicalFingerprint = requireText(entry.buildCanonicalFingerprint(mutationResult, revision), 'Canonical fingerprint');
|
|
2211
|
+
const payloadJson = canonicalWorkspaceSyncJson(entry.buildAuthoritativePayload(mutationResult, revision));
|
|
2212
|
+
this.db.prepare(`
|
|
2213
|
+
INSERT INTO workspace_sync_entity_revisions (
|
|
2214
|
+
tenant_id, workspace_id, domain, entity_id, contract_version,
|
|
2215
|
+
entity_revision, lifecycle_revision, coexistence_owned, metadata_owned, reopen_assignee_owned, last_sequence,
|
|
2216
|
+
canonical_fingerprint, updated_at
|
|
2217
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
|
2218
|
+
ON CONFLICT (tenant_id, workspace_id, domain, entity_id) DO UPDATE SET
|
|
2219
|
+
contract_version = excluded.contract_version,
|
|
2220
|
+
entity_revision = excluded.entity_revision,
|
|
2221
|
+
lifecycle_revision = excluded.lifecycle_revision,
|
|
2222
|
+
coexistence_owned = 1,
|
|
2223
|
+
metadata_owned = CASE
|
|
2224
|
+
WHEN excluded.metadata_owned = 1 THEN 1
|
|
2225
|
+
ELSE workspace_sync_entity_revisions.metadata_owned
|
|
2226
|
+
END,
|
|
2227
|
+
reopen_assignee_owned = CASE
|
|
2228
|
+
WHEN excluded.reopen_assignee_owned = 1 THEN 1
|
|
2229
|
+
ELSE workspace_sync_entity_revisions.reopen_assignee_owned
|
|
2230
|
+
END,
|
|
2231
|
+
last_sequence = excluded.last_sequence,
|
|
2232
|
+
canonical_fingerprint = excluded.canonical_fingerprint,
|
|
2233
|
+
updated_at = excluded.updated_at
|
|
2234
|
+
`).run(ids[0], ids[1], entry.domain, entry.entityId, WORKSPACE_SYNC_CONTRACT_VERSION, revision.entityRevision, revision.lifecycleRevision, entry.domain === 'task' && (entry.mutationKind === 'metadata-update'
|
|
2235
|
+
|| entry.mutationKind === 'create'
|
|
2236
|
+
|| entry.metadataOwned?.(mutationResult) === true) ? 1 : 0, entry.domain === 'task' && entry.reopenAssigneeOwned?.(mutationResult) === true ? 1 : 0, revision.sequence, canonicalFingerprint, now);
|
|
2237
|
+
this.db.prepare(`
|
|
2238
|
+
INSERT INTO workspace_sync_journal (
|
|
2239
|
+
tenant_id, workspace_id, sequence, contract_version, operation_id,
|
|
2240
|
+
client_id, domain, entity_id, mutation_kind, entity_revision,
|
|
2241
|
+
lifecycle_revision, content_sha256, size_bytes, payload_json, occurred_at
|
|
2242
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2243
|
+
`).run(ids[0], ids[1], revision.sequence, WORKSPACE_SYNC_CONTRACT_VERSION, ids[3], ids[2], entry.domain, entry.entityId, entry.mutationKind, revision.entityRevision, revision.lifecycleRevision, entry.contentSha256 ?? null, entry.sizeBytes, payloadJson, now);
|
|
2244
|
+
});
|
|
2245
|
+
const responseJson = canonicalWorkspaceSyncJson(input.buildResponse(mutationResult, revisions));
|
|
2246
|
+
const committed = this.db.prepare(`
|
|
2247
|
+
UPDATE workspace_sync_operation_results
|
|
2248
|
+
SET status = 'committed', response_json = ?, accepted_sequence = ?,
|
|
2249
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
2250
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2251
|
+
AND status = 'processing' AND request_fingerprint = ? AND lease_owner = ?
|
|
2252
|
+
`).run(responseJson, acceptedSequence, now, ...ids, requestFingerprint, leaseOwner);
|
|
2253
|
+
if (Number(committed.changes || 0) !== 1)
|
|
2254
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
2255
|
+
return { responseJson, acceptedSequence };
|
|
2256
|
+
})();
|
|
2257
|
+
}
|
|
2258
|
+
catch (error) {
|
|
2259
|
+
if (!(error instanceof WorkspaceSyncLeaseLostError)) {
|
|
2260
|
+
this.releaseFailedOperationReservation({
|
|
2261
|
+
identity: input.identity,
|
|
2262
|
+
requestFingerprint,
|
|
2263
|
+
leaseOwner,
|
|
2264
|
+
now,
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
throw error;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
enqueueLocalOperation(input) {
|
|
2271
|
+
const ids = operationParams(input.identity);
|
|
2272
|
+
const domain = requireText(input.domain, 'Domain');
|
|
2273
|
+
const entityId = requireText(input.entityId, 'Entity id');
|
|
2274
|
+
const mutationKind = requireText(input.mutationKind, 'Mutation kind');
|
|
2275
|
+
const dispatchClass = requireOutboxDispatchClass(input.dispatchClass ?? 'ordinary');
|
|
2276
|
+
const explicitEntityRevision = normalizeSequence(input.baseEntityRevision ?? 0);
|
|
2277
|
+
const explicitLifecycleRevision = normalizeSequence(input.baseLifecycleRevision ?? 0);
|
|
2278
|
+
let payloadJson = canonicalWorkspaceSyncJson(input.payload);
|
|
2279
|
+
let payloadFingerprint = fingerprintWorkspaceSyncPayload(input.payload);
|
|
2280
|
+
const now = requireIsoTimestamp(input.now, 'Enqueue time');
|
|
2281
|
+
const ownsTaskMetadata = domain === 'task' && (input.ownership?.taskMetadata ?? mutationKind === 'metadata-update');
|
|
2282
|
+
const ownsTaskLifecycle = domain === 'task' && (input.ownership?.taskLifecycle ?? ['archive', 'unarchive', 'complete', 'reopen'].includes(mutationKind));
|
|
2283
|
+
const ownsTaskAssignee = domain === 'task' && (input.ownership?.taskAssignee ?? mutationKind === 'reopen');
|
|
2284
|
+
const additionalTargets = (input.additionalTargets || []).map((target) => {
|
|
2285
|
+
const targetDomain = requireText(target.domain, 'Outbox target domain');
|
|
2286
|
+
return {
|
|
2287
|
+
domain: targetDomain,
|
|
2288
|
+
entityId: requireText(target.entityId, 'Outbox target entity id'),
|
|
2289
|
+
mutationKind: requireText(target.mutationKind, 'Outbox target mutation kind'),
|
|
2290
|
+
baseEntityRevision: normalizeSequence(target.baseEntityRevision),
|
|
2291
|
+
baseLifecycleRevision: normalizeSequence(target.baseLifecycleRevision),
|
|
2292
|
+
ownsTaskMetadata: targetDomain === 'task' && target.ownsTaskMetadata === true,
|
|
2293
|
+
ownsTaskLifecycle: targetDomain === 'task' && target.ownsTaskLifecycle === true,
|
|
2294
|
+
ownsTaskAssignee: targetDomain === 'task' && target.ownsTaskAssignee === true,
|
|
2295
|
+
};
|
|
2296
|
+
}).sort((left, right) => (compareCodeUnits(left.domain, right.domain) || compareCodeUnits(left.entityId, right.entityId)));
|
|
2297
|
+
if (additionalTargets.length > 0 && input.revisionSource !== 'current') {
|
|
2298
|
+
throw new Error('Multi-target local operations must capture current revisions atomically.');
|
|
2299
|
+
}
|
|
2300
|
+
if ((input.requiredCurrentRevision || input.validateLockedState) && input.revisionSource !== 'current') {
|
|
2301
|
+
throw new Error('Locked local enqueue preconditions require current revision capture.');
|
|
2302
|
+
}
|
|
2303
|
+
const queuedMetadataSuccessorDomain = input.queuedMetadataSuccessorDomain;
|
|
2304
|
+
if (queuedMetadataSuccessorDomain && (input.revisionSource !== 'current'
|
|
2305
|
+
|| domain !== queuedMetadataSuccessorDomain
|
|
2306
|
+
|| mutationKind !== 'metadata-update'
|
|
2307
|
+
|| additionalTargets.length > 0
|
|
2308
|
+
|| (domain === 'task' && (!ownsTaskMetadata || ownsTaskLifecycle || ownsTaskAssignee)))) {
|
|
2309
|
+
throw new Error('Queued successors are supported only for matching single-target metadata updates.');
|
|
2310
|
+
}
|
|
2311
|
+
const queuedTaskSuccessor = input.queuedTaskSuccessor === true;
|
|
2312
|
+
const queuedWorkflowRuntimeSuccessor = input.queuedWorkflowRuntimeSuccessor === true;
|
|
2313
|
+
const workflowTaskTarget = additionalTargets[0];
|
|
2314
|
+
if (queuedWorkflowRuntimeSuccessor && (input.revisionSource !== 'current'
|
|
2315
|
+
|| domain !== 'workflow-execution'
|
|
2316
|
+
|| !['transition', 'complete-step'].includes(mutationKind)
|
|
2317
|
+
|| additionalTargets.length > 1
|
|
2318
|
+
|| (additionalTargets.length === 1 && (workflowTaskTarget.domain !== 'task'
|
|
2319
|
+
|| workflowTaskTarget.mutationKind !== 'metadata-update'
|
|
2320
|
+
|| !workflowTaskTarget.ownsTaskMetadata
|
|
2321
|
+
|| workflowTaskTarget.ownsTaskLifecycle
|
|
2322
|
+
|| workflowTaskTarget.ownsTaskAssignee))
|
|
2323
|
+
|| ownsTaskMetadata || ownsTaskLifecycle || ownsTaskAssignee)) {
|
|
2324
|
+
throw new Error('Queued workflow runtime successors require a workflow execution root and optional task metadata target.');
|
|
2325
|
+
}
|
|
2326
|
+
const coalesceWorkflowAssignmentExecutionId = String(input.coalesceWorkflowAssignmentExecutionId || '').trim();
|
|
2327
|
+
if (coalesceWorkflowAssignmentExecutionId && (input.revisionSource !== 'current'
|
|
2328
|
+
|| domain !== 'task'
|
|
2329
|
+
|| mutationKind !== 'set-status'
|
|
2330
|
+
|| !queuedTaskSuccessor)) {
|
|
2331
|
+
throw new Error('Workflow assignment dependency coalescing requires a queued task status operation.');
|
|
2332
|
+
}
|
|
2333
|
+
const coalescedWorkflowPayloadFingerprint = coalesceWorkflowAssignmentExecutionId
|
|
2334
|
+
? (() => {
|
|
2335
|
+
const normalized = JSON.parse(payloadJson);
|
|
2336
|
+
delete normalized.workflowAssignment;
|
|
2337
|
+
return fingerprintWorkspaceSyncPayload(normalized);
|
|
2338
|
+
})()
|
|
2339
|
+
: null;
|
|
2340
|
+
const taskAggregateRootTarget = additionalTargets[0];
|
|
2341
|
+
const queueableTaskRoot = domain === 'task'
|
|
2342
|
+
&& TASK_ROOT_SUCCESSOR_MUTATIONS.has(mutationKind)
|
|
2343
|
+
&& additionalTargets.length === 0;
|
|
2344
|
+
const queueableTaskAggregate = Boolean(TASK_AGGREGATE_SUCCESSOR_MUTATIONS.get(domain)?.has(mutationKind))
|
|
2345
|
+
&& ((domain === 'task-comments' && mutationKind === 'reconcile' && additionalTargets.length === 0)
|
|
2346
|
+
|| (additionalTargets.length === 1
|
|
2347
|
+
&& taskAggregateRootTarget.domain === 'task'
|
|
2348
|
+
&& taskAggregateRootTarget.entityId === entityId
|
|
2349
|
+
&& taskAggregateRootTarget.mutationKind === 'metadata-update'
|
|
2350
|
+
&& taskAggregateRootTarget.ownsTaskMetadata
|
|
2351
|
+
&& !taskAggregateRootTarget.ownsTaskLifecycle
|
|
2352
|
+
&& !taskAggregateRootTarget.ownsTaskAssignee))
|
|
2353
|
+
&& !ownsTaskMetadata && !ownsTaskLifecycle && !ownsTaskAssignee;
|
|
2354
|
+
if (queuedTaskSuccessor && (input.revisionSource !== 'current' || (!queueableTaskRoot && !queueableTaskAggregate))) {
|
|
2355
|
+
throw new Error('Queued task successors require a recognized task root or aggregate operation.');
|
|
2356
|
+
}
|
|
2357
|
+
const additionalTargetKeys = new Set();
|
|
2358
|
+
for (const target of additionalTargets) {
|
|
2359
|
+
const key = `${target.domain}\u0000${target.entityId}`;
|
|
2360
|
+
if (key === `${domain}\u0000${entityId}` || additionalTargetKeys.has(key)) {
|
|
2361
|
+
throw new Error(`Duplicate workspace sync outbox target: ${target.domain}/${target.entityId}.`);
|
|
2362
|
+
}
|
|
2363
|
+
additionalTargetKeys.add(key);
|
|
2364
|
+
}
|
|
2365
|
+
return this.db.transaction(() => {
|
|
2366
|
+
const lock = this.db.prepare(`
|
|
2367
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
2368
|
+
VALUES (?, ?, ?, ?)
|
|
2369
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
2370
|
+
`);
|
|
2371
|
+
if (requiresInitiativeProjectionLock([{ domain, entityId }, ...additionalTargets])) {
|
|
2372
|
+
lock.run(ids[0], ids[1], WORKSPACE_SYNC_INITIATIVE_PROJECTION_LOCK, now);
|
|
2373
|
+
}
|
|
2374
|
+
if (input.requireV3PlanningOwnership === 'combined'
|
|
2375
|
+
|| requiresCombinedProjectionLock([{ domain, entityId }, ...additionalTargets])) {
|
|
2376
|
+
lock.run(ids[0], ids[1], WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
2377
|
+
}
|
|
2378
|
+
if (input.requireV3PlanningOwnership) {
|
|
2379
|
+
const ownership = input.requireV3PlanningOwnership === 'initiative'
|
|
2380
|
+
? this.readInitiativeProjectionOwnership({ tenantId: ids[0], workspaceId: ids[1] })
|
|
2381
|
+
: this.readCombinedProjectionOwnership({ tenantId: ids[0], workspaceId: ids[1] });
|
|
2382
|
+
if (ownership.owner !== 'v3')
|
|
2383
|
+
throw new WorkspaceSyncPlanningOwnershipChangedError();
|
|
2384
|
+
}
|
|
2385
|
+
if (input.revisionSource === 'current') {
|
|
2386
|
+
const targets = normalizeWorkspaceSyncLockTargets([
|
|
2387
|
+
{ domain, entityId },
|
|
2388
|
+
...additionalTargets.map((target) => ({ domain: target.domain, entityId: target.entityId })),
|
|
2389
|
+
]);
|
|
2390
|
+
for (const target of targets) {
|
|
2391
|
+
lock.run(ids[0], ids[1], `local-outbox:${target.domain}:${target.entityId}`, now);
|
|
2392
|
+
}
|
|
2393
|
+
if (input.requiredCurrentRevision) {
|
|
2394
|
+
const revision = this.db.prepare(`
|
|
2395
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2396
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision
|
|
2397
|
+
FROM workspace_sync_entity_revisions
|
|
2398
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
2399
|
+
`).get(ids[0], ids[1], domain, entityId);
|
|
2400
|
+
if (normalizeSequence(revision?.entity_revision ?? 0)
|
|
2401
|
+
!== normalizeSequence(input.requiredCurrentRevision.entityRevision)
|
|
2402
|
+
|| normalizeSequence(revision?.lifecycle_revision ?? 0)
|
|
2403
|
+
!== normalizeSequence(input.requiredCurrentRevision.lifecycleRevision))
|
|
2404
|
+
throw new WorkspaceSyncRevisionConflictError();
|
|
2405
|
+
}
|
|
2406
|
+
input.validateLockedState?.();
|
|
2407
|
+
const prior = this.db.prepare(`
|
|
2408
|
+
SELECT contract_version, dispatch_class, domain, entity_id, mutation_kind, payload_fingerprint,
|
|
2409
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee
|
|
2410
|
+
FROM workspace_sync_outbox
|
|
2411
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2412
|
+
`).get(...ids);
|
|
2413
|
+
if (prior) {
|
|
2414
|
+
const matches = Number(prior.contract_version) === WORKSPACE_SYNC_CONTRACT_VERSION
|
|
2415
|
+
&& prior.dispatch_class === dispatchClass
|
|
2416
|
+
&& prior.domain === domain
|
|
2417
|
+
&& prior.entity_id === entityId
|
|
2418
|
+
&& prior.mutation_kind === mutationKind
|
|
2419
|
+
&& Number(prior.owns_task_metadata) === (ownsTaskMetadata ? 1 : 0)
|
|
2420
|
+
&& Number(prior.owns_task_lifecycle) === (ownsTaskLifecycle ? 1 : 0)
|
|
2421
|
+
&& Number(prior.owns_task_assignee) === (ownsTaskAssignee ? 1 : 0)
|
|
2422
|
+
&& (prior.payload_fingerprint === payloadFingerprint
|
|
2423
|
+
|| prior.payload_fingerprint === coalescedWorkflowPayloadFingerprint);
|
|
2424
|
+
const persistedTargets = this.db.prepare(`
|
|
2425
|
+
SELECT domain, entity_id, mutation_kind,
|
|
2426
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
2427
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
2428
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee
|
|
2429
|
+
FROM workspace_sync_outbox_targets
|
|
2430
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2431
|
+
AND NOT (domain = ? AND entity_id = ?)
|
|
2432
|
+
ORDER BY domain, entity_id
|
|
2433
|
+
`).all(...ids, domain, entityId);
|
|
2434
|
+
const targetsMatch = persistedTargets.length === additionalTargets.length
|
|
2435
|
+
&& persistedTargets.every((persisted, index) => {
|
|
2436
|
+
const expected = additionalTargets[index];
|
|
2437
|
+
return persisted.domain === expected.domain
|
|
2438
|
+
&& persisted.entity_id === expected.entityId
|
|
2439
|
+
&& persisted.mutation_kind === expected.mutationKind
|
|
2440
|
+
&& normalizeSequence(persisted.base_entity_revision) === expected.baseEntityRevision
|
|
2441
|
+
&& normalizeSequence(persisted.base_lifecycle_revision) === expected.baseLifecycleRevision
|
|
2442
|
+
&& Number(persisted.owns_task_metadata) === (expected.ownsTaskMetadata ? 1 : 0)
|
|
2443
|
+
&& Number(persisted.owns_task_lifecycle) === (expected.ownsTaskLifecycle ? 1 : 0)
|
|
2444
|
+
&& Number(persisted.owns_task_assignee) === (expected.ownsTaskAssignee ? 1 : 0);
|
|
2445
|
+
});
|
|
2446
|
+
if (!matches || !targetsMatch)
|
|
2447
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
2448
|
+
return { enqueued: false };
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
const current = input.revisionSource === 'current'
|
|
2452
|
+
? this.db.prepare(`
|
|
2453
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2454
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision
|
|
2455
|
+
FROM workspace_sync_entity_revisions
|
|
2456
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
2457
|
+
`).get(ids[0], ids[1], domain, entityId)
|
|
2458
|
+
: undefined;
|
|
2459
|
+
const baseEntityRevision = input.revisionSource === 'current'
|
|
2460
|
+
? normalizeSequence(current?.entity_revision ?? 0)
|
|
2461
|
+
: explicitEntityRevision;
|
|
2462
|
+
const baseLifecycleRevision = input.revisionSource === 'current'
|
|
2463
|
+
? normalizeSequence(current?.lifecycle_revision ?? 0)
|
|
2464
|
+
: explicitLifecycleRevision;
|
|
2465
|
+
let queuedBehindActiveOperation = false;
|
|
2466
|
+
if (input.revisionSource === 'current') {
|
|
2467
|
+
const targets = [{ domain, entityId }, ...additionalTargets];
|
|
2468
|
+
for (const target of targets) {
|
|
2469
|
+
const active = this.db.prepare(`
|
|
2470
|
+
SELECT outbox.operation_id, outbox.domain, outbox.entity_id, outbox.mutation_kind,
|
|
2471
|
+
outbox.status, outbox.attempt_count, outbox.next_attempt_at,
|
|
2472
|
+
outbox.lease_expires_at, outbox.last_error_code,
|
|
2473
|
+
outbox.created_at, outbox.updated_at,
|
|
2474
|
+
outbox.payload_json,
|
|
2475
|
+
outbox.owns_task_metadata, outbox.owns_task_lifecycle,
|
|
2476
|
+
outbox.owns_task_assignee,
|
|
2477
|
+
(SELECT COUNT(*) FROM workspace_sync_outbox_targets AS counted_target
|
|
2478
|
+
WHERE counted_target.tenant_id = outbox.tenant_id
|
|
2479
|
+
AND counted_target.workspace_id = outbox.workspace_id
|
|
2480
|
+
AND counted_target.client_id = outbox.client_id
|
|
2481
|
+
AND counted_target.operation_id = outbox.operation_id) AS target_count,
|
|
2482
|
+
(SELECT COUNT(*) FROM workspace_sync_outbox_targets AS aggregate_target
|
|
2483
|
+
WHERE aggregate_target.tenant_id = outbox.tenant_id
|
|
2484
|
+
AND aggregate_target.workspace_id = outbox.workspace_id
|
|
2485
|
+
AND aggregate_target.client_id = outbox.client_id
|
|
2486
|
+
AND aggregate_target.operation_id = outbox.operation_id
|
|
2487
|
+
AND (
|
|
2488
|
+
(aggregate_target.domain = outbox.domain
|
|
2489
|
+
AND aggregate_target.entity_id = outbox.entity_id
|
|
2490
|
+
AND aggregate_target.mutation_kind = outbox.mutation_kind
|
|
2491
|
+
AND aggregate_target.owns_task_metadata = 0
|
|
2492
|
+
AND aggregate_target.owns_task_lifecycle = 0
|
|
2493
|
+
AND aggregate_target.owns_task_assignee = 0)
|
|
2494
|
+
OR (aggregate_target.domain = 'task'
|
|
2495
|
+
AND aggregate_target.entity_id = outbox.entity_id
|
|
2496
|
+
AND aggregate_target.mutation_kind = 'metadata-update'
|
|
2497
|
+
AND aggregate_target.owns_task_metadata = 1
|
|
2498
|
+
AND aggregate_target.owns_task_lifecycle = 0
|
|
2499
|
+
AND aggregate_target.owns_task_assignee = 0)
|
|
2500
|
+
)) AS task_aggregate_target_count
|
|
2501
|
+
FROM workspace_sync_outbox AS outbox
|
|
2502
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
2503
|
+
AND outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
2504
|
+
AND NOT (outbox.client_id = ? AND outbox.operation_id = ?)
|
|
2505
|
+
AND (
|
|
2506
|
+
(outbox.domain = ? AND outbox.entity_id = ?)
|
|
2507
|
+
OR EXISTS (
|
|
2508
|
+
SELECT 1 FROM workspace_sync_outbox_targets AS persisted_target
|
|
2509
|
+
WHERE persisted_target.tenant_id = outbox.tenant_id
|
|
2510
|
+
AND persisted_target.workspace_id = outbox.workspace_id
|
|
2511
|
+
AND persisted_target.client_id = outbox.client_id
|
|
2512
|
+
AND persisted_target.operation_id = outbox.operation_id
|
|
2513
|
+
AND persisted_target.domain = ? AND persisted_target.entity_id = ?
|
|
2514
|
+
)
|
|
2515
|
+
)
|
|
2516
|
+
ORDER BY CASE outbox.status
|
|
2517
|
+
WHEN 'blocked' THEN 0
|
|
2518
|
+
WHEN 'in_flight' THEN 1
|
|
2519
|
+
ELSE 2
|
|
2520
|
+
END,
|
|
2521
|
+
outbox.enqueue_sequence
|
|
2522
|
+
`).all(ids[0], ids[1], ids[2], ids[3], target.domain, target.entityId, target.domain, target.entityId);
|
|
2523
|
+
if (active.length > 0) {
|
|
2524
|
+
const isCompatibleTaskAggregate = (row) => {
|
|
2525
|
+
const activeDomain = String(row.domain || '');
|
|
2526
|
+
const activeMutationKind = String(row.mutation_kind || '');
|
|
2527
|
+
const isReconciliation = activeDomain === 'task-comments'
|
|
2528
|
+
&& activeMutationKind === 'reconcile'
|
|
2529
|
+
&& Number(row.target_count) === 1;
|
|
2530
|
+
return row.entity_id === entityId
|
|
2531
|
+
&& Boolean(TASK_AGGREGATE_SUCCESSOR_MUTATIONS.get(activeDomain)
|
|
2532
|
+
?.has(activeMutationKind))
|
|
2533
|
+
&& Number(row.owns_task_metadata) === 0
|
|
2534
|
+
&& Number(row.owns_task_lifecycle) === 0
|
|
2535
|
+
&& Number(row.owns_task_assignee) === 0
|
|
2536
|
+
&& (isReconciliation || (Number(row.target_count) === 2
|
|
2537
|
+
&& Number(row.task_aggregate_target_count) === 2));
|
|
2538
|
+
};
|
|
2539
|
+
const isCompatibleWorkflowRuntimeAggregate = (row) => (row.domain === 'workflow-execution'
|
|
2540
|
+
&& row.mutation_kind === 'complete-step'
|
|
2541
|
+
&& Number(row.owns_task_metadata) === 0
|
|
2542
|
+
&& Number(row.owns_task_lifecycle) === 0
|
|
2543
|
+
&& Number(row.owns_task_assignee) === 0
|
|
2544
|
+
&& Number(row.target_count) === 2);
|
|
2545
|
+
const compatibleMetadataSuccessor = Boolean(queuedMetadataSuccessorDomain)
|
|
2546
|
+
&& target.domain === domain
|
|
2547
|
+
&& target.entityId === entityId
|
|
2548
|
+
&& active.every((row) => ((row.domain === queuedMetadataSuccessorDomain
|
|
2549
|
+
&& row.entity_id === entityId
|
|
2550
|
+
&& row.mutation_kind === 'metadata-update'
|
|
2551
|
+
&& (row.domain !== 'task' || (Number(row.owns_task_metadata) === 1
|
|
2552
|
+
&& Number(row.owns_task_lifecycle) === 0
|
|
2553
|
+
&& Number(row.owns_task_assignee) === 0))
|
|
2554
|
+
&& Number(row.target_count) === 1)
|
|
2555
|
+
|| (queuedMetadataSuccessorDomain === 'task'
|
|
2556
|
+
&& (isCompatibleTaskAggregate(row)
|
|
2557
|
+
|| isCompatibleWorkflowRuntimeAggregate(row)))));
|
|
2558
|
+
const compatibleTaskSuccessor = queuedTaskSuccessor
|
|
2559
|
+
&& target.entityId === entityId
|
|
2560
|
+
&& active.every((row) => {
|
|
2561
|
+
const activeDomain = String(row.domain || '');
|
|
2562
|
+
const activeMutationKind = String(row.mutation_kind || '');
|
|
2563
|
+
const isTaskRoot = activeDomain === 'task'
|
|
2564
|
+
&& row.entity_id === entityId
|
|
2565
|
+
&& (TASK_ROOT_SUCCESSOR_MUTATIONS.has(activeMutationKind)
|
|
2566
|
+
|| (queueableTaskAggregate && activeMutationKind === 'create'))
|
|
2567
|
+
&& Number(row.target_count) === 1;
|
|
2568
|
+
return isTaskRoot
|
|
2569
|
+
|| isCompatibleTaskAggregate(row)
|
|
2570
|
+
|| isCompatibleWorkflowRuntimeAggregate(row);
|
|
2571
|
+
});
|
|
2572
|
+
const compatibleWorkflowRuntimeSuccessor = queuedWorkflowRuntimeSuccessor
|
|
2573
|
+
&& active.every((row) => (row.domain === 'workflow-execution'
|
|
2574
|
+
&& row.entity_id === entityId
|
|
2575
|
+
&& ['transition', 'complete-step'].includes(String(row.mutation_kind || ''))
|
|
2576
|
+
&& Number(row.owns_task_metadata) === 0
|
|
2577
|
+
&& Number(row.owns_task_lifecycle) === 0
|
|
2578
|
+
&& Number(row.owns_task_assignee) === 0
|
|
2579
|
+
&& [1, 2].includes(Number(row.target_count))));
|
|
2580
|
+
if (!compatibleMetadataSuccessor
|
|
2581
|
+
&& !compatibleTaskSuccessor
|
|
2582
|
+
&& !compatibleWorkflowRuntimeSuccessor) {
|
|
2583
|
+
throw workspaceSyncEntityBusyError({ target, active, now });
|
|
2584
|
+
}
|
|
2585
|
+
queuedBehindActiveOperation = true;
|
|
2586
|
+
if (coalesceWorkflowAssignmentExecutionId
|
|
2587
|
+
&& target.domain === 'task' && target.entityId === entityId) {
|
|
2588
|
+
const dependencyAlreadyQueued = active.some((row) => {
|
|
2589
|
+
try {
|
|
2590
|
+
const activePayload = JSON.parse(String(row.payload_json || ''));
|
|
2591
|
+
return String(activePayload.workflowAssignment?.execution?.id || '')
|
|
2592
|
+
=== coalesceWorkflowAssignmentExecutionId;
|
|
2593
|
+
}
|
|
2594
|
+
catch {
|
|
2595
|
+
return false;
|
|
2596
|
+
}
|
|
2597
|
+
});
|
|
2598
|
+
if (dependencyAlreadyQueued) {
|
|
2599
|
+
const normalizedPayload = JSON.parse(payloadJson);
|
|
2600
|
+
delete normalizedPayload.workflowAssignment;
|
|
2601
|
+
payloadJson = canonicalWorkspaceSyncJson(normalizedPayload);
|
|
2602
|
+
payloadFingerprint = fingerprintWorkspaceSyncPayload(normalizedPayload);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
for (const target of additionalTargets) {
|
|
2608
|
+
const revision = this.db.prepare(`
|
|
2609
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2610
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision
|
|
2611
|
+
FROM workspace_sync_entity_revisions
|
|
2612
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
2613
|
+
`).get(ids[0], ids[1], target.domain, target.entityId);
|
|
2614
|
+
if (normalizeSequence(revision?.entity_revision ?? 0) !== target.baseEntityRevision
|
|
2615
|
+
|| normalizeSequence(revision?.lifecycle_revision ?? 0) !== target.baseLifecycleRevision)
|
|
2616
|
+
throw new WorkspaceSyncRevisionConflictError();
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
const enqueueSequence = this.allocateOutboxEnqueueSequence(ids[0], ids[1], now);
|
|
2620
|
+
const inserted = this.db.prepare(`
|
|
2621
|
+
INSERT INTO workspace_sync_outbox (
|
|
2622
|
+
tenant_id, workspace_id, operation_id, contract_version, client_id,
|
|
2623
|
+
dispatch_class, domain, entity_id, mutation_kind, owns_task_metadata,
|
|
2624
|
+
owns_task_lifecycle, owns_task_assignee, base_entity_revision,
|
|
2625
|
+
base_lifecycle_revision, payload_json, payload_fingerprint,
|
|
2626
|
+
status, attempt_count, enqueue_sequence, rebase_before_first_attempt,
|
|
2627
|
+
next_attempt_at, created_at, updated_at
|
|
2628
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?, ?)
|
|
2629
|
+
ON CONFLICT (tenant_id, workspace_id, client_id, operation_id) DO NOTHING
|
|
2630
|
+
`).run(ids[0], ids[1], ids[3], WORKSPACE_SYNC_CONTRACT_VERSION, ids[2], dispatchClass, domain, entityId, mutationKind, ownsTaskMetadata ? 1 : 0, ownsTaskLifecycle ? 1 : 0, ownsTaskAssignee ? 1 : 0, baseEntityRevision, baseLifecycleRevision, payloadJson, payloadFingerprint, enqueueSequence, queuedBehindActiveOperation ? 1 : 0, now, now, now);
|
|
2631
|
+
if (Number(inserted.changes || 0) === 1) {
|
|
2632
|
+
const insertTarget = this.db.prepare(`
|
|
2633
|
+
INSERT INTO workspace_sync_outbox_targets (
|
|
2634
|
+
tenant_id, workspace_id, client_id, operation_id, domain, entity_id,
|
|
2635
|
+
mutation_kind, base_entity_revision, base_lifecycle_revision,
|
|
2636
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee, created_at
|
|
2637
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2638
|
+
`);
|
|
2639
|
+
const targets = [{
|
|
2640
|
+
domain, entityId, mutationKind, baseEntityRevision, baseLifecycleRevision,
|
|
2641
|
+
ownsTaskMetadata, ownsTaskLifecycle, ownsTaskAssignee,
|
|
2642
|
+
}, ...additionalTargets];
|
|
2643
|
+
for (const target of targets) {
|
|
2644
|
+
insertTarget.run(ids[0], ids[1], ids[2], ids[3], target.domain, target.entityId, target.mutationKind, target.baseEntityRevision, target.baseLifecycleRevision, target.ownsTaskMetadata ? 1 : 0, target.ownsTaskLifecycle ? 1 : 0, target.ownsTaskAssignee ? 1 : 0, now);
|
|
2645
|
+
}
|
|
2646
|
+
return { enqueued: true, mutationResult: input.applyLocalMutation() };
|
|
2647
|
+
}
|
|
2648
|
+
const existing = this.db.prepare(`
|
|
2649
|
+
SELECT contract_version, dispatch_class, domain, entity_id, mutation_kind,
|
|
2650
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
2651
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
2652
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
2653
|
+
payload_fingerprint
|
|
2654
|
+
FROM workspace_sync_outbox
|
|
2655
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
2656
|
+
`).get(...ids);
|
|
2657
|
+
const matches = existing
|
|
2658
|
+
&& Number(existing.contract_version) === WORKSPACE_SYNC_CONTRACT_VERSION
|
|
2659
|
+
&& existing.dispatch_class === dispatchClass
|
|
2660
|
+
&& existing.domain === domain
|
|
2661
|
+
&& existing.entity_id === entityId
|
|
2662
|
+
&& existing.mutation_kind === mutationKind
|
|
2663
|
+
&& Number(existing.owns_task_metadata) === (ownsTaskMetadata ? 1 : 0)
|
|
2664
|
+
&& Number(existing.owns_task_lifecycle) === (ownsTaskLifecycle ? 1 : 0)
|
|
2665
|
+
&& Number(existing.owns_task_assignee) === (ownsTaskAssignee ? 1 : 0)
|
|
2666
|
+
&& normalizeSequence(existing.base_entity_revision) === baseEntityRevision
|
|
2667
|
+
&& normalizeSequence(existing.base_lifecycle_revision) === baseLifecycleRevision
|
|
2668
|
+
&& (existing.payload_fingerprint === payloadFingerprint
|
|
2669
|
+
|| existing.payload_fingerprint === coalescedWorkflowPayloadFingerprint);
|
|
2670
|
+
if (!matches)
|
|
2671
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
2672
|
+
return { enqueued: false };
|
|
2673
|
+
})();
|
|
2674
|
+
}
|
|
2675
|
+
listTaskMetadataOwnership(input) {
|
|
2676
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2677
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2678
|
+
const rows = this.db.prepare(`
|
|
2679
|
+
WITH ownership_sources AS (
|
|
2680
|
+
SELECT entity_id,
|
|
2681
|
+
0 AS active_operation,
|
|
2682
|
+
0 AS active_create_operation,
|
|
2683
|
+
0 AS active_delete_operation,
|
|
2684
|
+
CASE WHEN EXISTS (
|
|
2685
|
+
SELECT 1 FROM workspace_sync_journal AS delete_journal
|
|
2686
|
+
WHERE delete_journal.tenant_id = workspace_sync_entity_revisions.tenant_id
|
|
2687
|
+
AND delete_journal.workspace_id = workspace_sync_entity_revisions.workspace_id
|
|
2688
|
+
AND delete_journal.domain = 'task'
|
|
2689
|
+
AND delete_journal.entity_id = workspace_sync_entity_revisions.entity_id
|
|
2690
|
+
AND delete_journal.mutation_kind = 'delete'
|
|
2691
|
+
AND delete_journal.sequence = workspace_sync_entity_revisions.last_sequence
|
|
2692
|
+
) THEN 1 ELSE 0 END AS delete_owned,
|
|
2693
|
+
metadata_owned,
|
|
2694
|
+
0 AS active_lifecycle_operation,
|
|
2695
|
+
0 AS active_reopen_operation,
|
|
2696
|
+
reopen_assignee_owned,
|
|
2697
|
+
CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2698
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2699
|
+
CAST(last_sequence AS TEXT) AS last_sequence
|
|
2700
|
+
FROM workspace_sync_entity_revisions
|
|
2701
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
2702
|
+
AND contract_version = ? AND domain = 'task'
|
|
2703
|
+
AND coexistence_owned = 1
|
|
2704
|
+
AND (metadata_owned = 1 OR lifecycle_revision > 0)
|
|
2705
|
+
UNION ALL
|
|
2706
|
+
SELECT target.entity_id,
|
|
2707
|
+
CASE WHEN target.owns_task_metadata = 1 OR target.mutation_kind = 'metadata-update' THEN 1 ELSE 0 END AS active_operation,
|
|
2708
|
+
CASE WHEN target.mutation_kind = 'create' THEN 1 ELSE 0 END AS active_create_operation,
|
|
2709
|
+
CASE WHEN target.mutation_kind = 'delete' THEN 1 ELSE 0 END AS active_delete_operation,
|
|
2710
|
+
CASE WHEN target.mutation_kind = 'delete' THEN 1 ELSE 0 END AS delete_owned,
|
|
2711
|
+
COALESCE(revisions.metadata_owned, 0) AS metadata_owned,
|
|
2712
|
+
CASE WHEN target.owns_task_lifecycle = 1 OR target.mutation_kind IN ('archive', 'unarchive', 'complete', 'reopen') THEN 1 ELSE 0 END AS active_lifecycle_operation,
|
|
2713
|
+
CASE WHEN target.owns_task_assignee = 1 OR target.mutation_kind = 'reopen' THEN 1 ELSE 0 END AS active_reopen_operation,
|
|
2714
|
+
0 AS reopen_assignee_owned,
|
|
2715
|
+
CAST(revisions.entity_revision AS TEXT) AS entity_revision,
|
|
2716
|
+
CAST(revisions.lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2717
|
+
CAST(revisions.last_sequence AS TEXT) AS last_sequence
|
|
2718
|
+
FROM workspace_sync_outbox_targets AS target
|
|
2719
|
+
INNER JOIN workspace_sync_outbox AS outbox
|
|
2720
|
+
ON outbox.tenant_id = target.tenant_id
|
|
2721
|
+
AND outbox.workspace_id = target.workspace_id
|
|
2722
|
+
AND outbox.client_id = target.client_id
|
|
2723
|
+
AND outbox.operation_id = target.operation_id
|
|
2724
|
+
LEFT JOIN workspace_sync_entity_revisions AS revisions
|
|
2725
|
+
ON revisions.tenant_id = target.tenant_id
|
|
2726
|
+
AND revisions.workspace_id = target.workspace_id
|
|
2727
|
+
AND revisions.contract_version = outbox.contract_version
|
|
2728
|
+
AND revisions.domain = target.domain
|
|
2729
|
+
AND revisions.entity_id = target.entity_id
|
|
2730
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
2731
|
+
AND outbox.contract_version = ? AND target.domain = 'task'
|
|
2732
|
+
AND (target.mutation_kind = 'create'
|
|
2733
|
+
OR target.owns_task_metadata = 1
|
|
2734
|
+
OR target.owns_task_lifecycle = 1
|
|
2735
|
+
OR target.owns_task_assignee = 1
|
|
2736
|
+
OR target.mutation_kind IN ('metadata-update', 'delete', 'archive', 'unarchive', 'complete', 'reopen'))
|
|
2737
|
+
AND (
|
|
2738
|
+
outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
2739
|
+
OR (target.mutation_kind = 'delete' AND outbox.status = 'accepted')
|
|
2740
|
+
)
|
|
2741
|
+
UNION ALL
|
|
2742
|
+
SELECT outbox.entity_id,
|
|
2743
|
+
CASE WHEN outbox.owns_task_metadata = 1 OR outbox.mutation_kind = 'metadata-update' THEN 1 ELSE 0 END AS active_operation,
|
|
2744
|
+
CASE WHEN outbox.mutation_kind = 'create' THEN 1 ELSE 0 END AS active_create_operation,
|
|
2745
|
+
CASE WHEN outbox.mutation_kind = 'delete' THEN 1 ELSE 0 END AS active_delete_operation,
|
|
2746
|
+
CASE WHEN outbox.mutation_kind = 'delete' THEN 1 ELSE 0 END AS delete_owned,
|
|
2747
|
+
COALESCE(revisions.metadata_owned, 0) AS metadata_owned,
|
|
2748
|
+
CASE WHEN outbox.owns_task_lifecycle = 1 OR outbox.mutation_kind IN ('archive', 'unarchive', 'complete', 'reopen') THEN 1 ELSE 0 END AS active_lifecycle_operation,
|
|
2749
|
+
CASE WHEN outbox.owns_task_assignee = 1 OR outbox.mutation_kind = 'reopen' THEN 1 ELSE 0 END AS active_reopen_operation,
|
|
2750
|
+
0 AS reopen_assignee_owned,
|
|
2751
|
+
CAST(revisions.entity_revision AS TEXT) AS entity_revision,
|
|
2752
|
+
CAST(revisions.lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2753
|
+
CAST(revisions.last_sequence AS TEXT) AS last_sequence
|
|
2754
|
+
FROM workspace_sync_outbox AS outbox
|
|
2755
|
+
LEFT JOIN workspace_sync_entity_revisions AS revisions
|
|
2756
|
+
ON revisions.tenant_id = outbox.tenant_id
|
|
2757
|
+
AND revisions.workspace_id = outbox.workspace_id
|
|
2758
|
+
AND revisions.contract_version = outbox.contract_version
|
|
2759
|
+
AND revisions.domain = outbox.domain
|
|
2760
|
+
AND revisions.entity_id = outbox.entity_id
|
|
2761
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
2762
|
+
AND outbox.contract_version = ? AND outbox.domain = 'task'
|
|
2763
|
+
AND (outbox.mutation_kind = 'create'
|
|
2764
|
+
OR outbox.owns_task_metadata = 1
|
|
2765
|
+
OR outbox.owns_task_lifecycle = 1
|
|
2766
|
+
OR outbox.owns_task_assignee = 1
|
|
2767
|
+
OR outbox.mutation_kind IN ('metadata-update', 'delete', 'archive', 'unarchive', 'complete', 'reopen'))
|
|
2768
|
+
AND (
|
|
2769
|
+
outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
2770
|
+
OR (outbox.mutation_kind = 'delete' AND outbox.status = 'accepted')
|
|
2771
|
+
)
|
|
2772
|
+
)
|
|
2773
|
+
SELECT entity_id,
|
|
2774
|
+
MAX(active_operation) AS active_operation,
|
|
2775
|
+
MAX(active_create_operation) AS active_create_operation,
|
|
2776
|
+
MAX(active_delete_operation) AS active_delete_operation,
|
|
2777
|
+
MAX(delete_owned) AS delete_owned,
|
|
2778
|
+
MAX(metadata_owned) AS metadata_owned,
|
|
2779
|
+
MAX(active_lifecycle_operation) AS active_lifecycle_operation,
|
|
2780
|
+
MAX(active_reopen_operation) AS active_reopen_operation,
|
|
2781
|
+
MAX(reopen_assignee_owned) AS reopen_assignee_owned,
|
|
2782
|
+
MAX(entity_revision) AS entity_revision,
|
|
2783
|
+
MAX(lifecycle_revision) AS lifecycle_revision,
|
|
2784
|
+
MAX(last_sequence) AS last_sequence
|
|
2785
|
+
FROM ownership_sources
|
|
2786
|
+
GROUP BY entity_id
|
|
2787
|
+
ORDER BY entity_id ASC
|
|
2788
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
2789
|
+
return rows.map((row) => ({
|
|
2790
|
+
taskId: requireText(row.entity_id, 'Task id'),
|
|
2791
|
+
activeOperation: Number(row.active_operation) === 1,
|
|
2792
|
+
activeCreateOperation: Number(row.active_create_operation) === 1,
|
|
2793
|
+
...(Number(row.active_delete_operation) === 1 ? { activeDeleteOperation: true } : {}),
|
|
2794
|
+
...(Number(row.delete_owned) === 1 ? { deleteOwned: true } : {}),
|
|
2795
|
+
metadataOwned: Number(row.metadata_owned) === 1,
|
|
2796
|
+
activeLifecycleOperation: Number(row.active_lifecycle_operation) === 1,
|
|
2797
|
+
activeReopenOperation: Number(row.active_reopen_operation) === 1,
|
|
2798
|
+
reopenAssigneeOwned: Number(row.reopen_assignee_owned) === 1,
|
|
2799
|
+
entityRevision: row.entity_revision === null || row.entity_revision === undefined
|
|
2800
|
+
? null : normalizeSequence(row.entity_revision),
|
|
2801
|
+
lifecycleRevision: row.lifecycle_revision === null || row.lifecycle_revision === undefined
|
|
2802
|
+
? null : normalizeSequence(row.lifecycle_revision),
|
|
2803
|
+
lastSequence: row.last_sequence === null || row.last_sequence === undefined
|
|
2804
|
+
? null : normalizeSequence(row.last_sequence),
|
|
2805
|
+
}));
|
|
2806
|
+
}
|
|
2807
|
+
listTaskRevisionProjections(input) {
|
|
2808
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2809
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2810
|
+
const taskIds = Array.from(new Set(input.taskIds.map((taskId) => requireText(taskId, 'Task id')))).sort();
|
|
2811
|
+
if (taskIds.length === 0)
|
|
2812
|
+
return [];
|
|
2813
|
+
const placeholders = taskIds.map(() => '?').join(', ');
|
|
2814
|
+
const rows = this.db.prepare(`
|
|
2815
|
+
SELECT entity_id,
|
|
2816
|
+
CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2817
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2818
|
+
CAST(last_sequence AS TEXT) AS last_sequence,
|
|
2819
|
+
canonical_fingerprint,
|
|
2820
|
+
CASE WHEN EXISTS (
|
|
2821
|
+
SELECT 1 FROM workspace_sync_task_taxonomy_ownership AS taxonomy_ownership
|
|
2822
|
+
WHERE taxonomy_ownership.tenant_id = workspace_sync_entity_revisions.tenant_id
|
|
2823
|
+
AND taxonomy_ownership.workspace_id = workspace_sync_entity_revisions.workspace_id
|
|
2824
|
+
AND taxonomy_ownership.task_id = workspace_sync_entity_revisions.entity_id
|
|
2825
|
+
) THEN 1 ELSE 0 END AS taxonomy_owned
|
|
2826
|
+
FROM workspace_sync_entity_revisions
|
|
2827
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
2828
|
+
AND contract_version = ? AND domain = 'task'
|
|
2829
|
+
AND entity_id IN (${placeholders})
|
|
2830
|
+
AND entity_revision > 0 AND last_sequence IS NOT NULL
|
|
2831
|
+
AND canonical_fingerprint IS NOT NULL
|
|
2832
|
+
ORDER BY entity_id ASC
|
|
2833
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, ...taskIds);
|
|
2834
|
+
return rows.map((row) => ({
|
|
2835
|
+
taskId: requireText(row.entity_id, 'Task id'),
|
|
2836
|
+
entityRevision: normalizeSequence(row.entity_revision),
|
|
2837
|
+
lifecycleRevision: normalizeSequence(row.lifecycle_revision),
|
|
2838
|
+
lastSequence: normalizeSequence(row.last_sequence),
|
|
2839
|
+
canonicalFingerprint: requireText(row.canonical_fingerprint, 'Canonical fingerprint'),
|
|
2840
|
+
taxonomyOwned: Number(row.taxonomy_owned) === 1,
|
|
2841
|
+
}));
|
|
2842
|
+
}
|
|
2843
|
+
listPlanningRevisionProjections(input) {
|
|
2844
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2845
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2846
|
+
const domain = input.domain;
|
|
2847
|
+
const entityIds = Array.from(new Set(input.entityIds.map((entityId) => requireText(entityId, 'Planning entity id')))).sort();
|
|
2848
|
+
if (entityIds.length === 0)
|
|
2849
|
+
return [];
|
|
2850
|
+
if (entityIds.length > 100)
|
|
2851
|
+
throw new Error('Planning revision projection supports at most 100 entity ids.');
|
|
2852
|
+
const placeholders = entityIds.map(() => '?').join(', ');
|
|
2853
|
+
const rows = this.db.prepare(`
|
|
2854
|
+
SELECT revision.entity_id,
|
|
2855
|
+
revision.coexistence_owned,
|
|
2856
|
+
journal.mutation_kind,
|
|
2857
|
+
CAST(revision.entity_revision AS TEXT) AS entity_revision,
|
|
2858
|
+
CAST(revision.lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2859
|
+
CAST(revision.last_sequence AS TEXT) AS last_sequence,
|
|
2860
|
+
revision.canonical_fingerprint,
|
|
2861
|
+
journal.occurred_at
|
|
2862
|
+
FROM workspace_sync_entity_revisions AS revision
|
|
2863
|
+
LEFT JOIN workspace_sync_journal AS journal
|
|
2864
|
+
ON journal.tenant_id = revision.tenant_id
|
|
2865
|
+
AND journal.workspace_id = revision.workspace_id
|
|
2866
|
+
AND journal.contract_version = revision.contract_version
|
|
2867
|
+
AND journal.domain = revision.domain
|
|
2868
|
+
AND journal.entity_id = revision.entity_id
|
|
2869
|
+
AND journal.sequence = revision.last_sequence
|
|
2870
|
+
WHERE revision.tenant_id = ? AND revision.workspace_id = ?
|
|
2871
|
+
AND revision.contract_version = ? AND revision.domain = ?
|
|
2872
|
+
AND revision.entity_id IN (${placeholders})
|
|
2873
|
+
AND revision.entity_revision > 0
|
|
2874
|
+
AND revision.last_sequence IS NOT NULL
|
|
2875
|
+
AND revision.canonical_fingerprint IS NOT NULL
|
|
2876
|
+
ORDER BY revision.entity_id ASC
|
|
2877
|
+
`).all(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, domain, ...entityIds);
|
|
2878
|
+
const allowedKinds = new Set(['create', 'metadata-update', 'append-comment', 'archive', 'unarchive']);
|
|
2879
|
+
return rows.map((row) => {
|
|
2880
|
+
const mutationKind = requireText(row.mutation_kind, 'Planning mutation kind');
|
|
2881
|
+
if (!allowedKinds.has(mutationKind)) {
|
|
2882
|
+
throw new Error(`Unsupported ${domain} journal mutation kind: ${mutationKind}`);
|
|
2883
|
+
}
|
|
2884
|
+
return {
|
|
2885
|
+
entityId: requireText(row.entity_id, 'Planning entity id'),
|
|
2886
|
+
coexistenceOwned: Number(row.coexistence_owned) === 1,
|
|
2887
|
+
mutationKind: mutationKind,
|
|
2888
|
+
entityRevision: normalizeSequence(row.entity_revision),
|
|
2889
|
+
lifecycleRevision: normalizeSequence(row.lifecycle_revision),
|
|
2890
|
+
lastSequence: normalizeSequence(row.last_sequence),
|
|
2891
|
+
canonicalFingerprint: requireText(row.canonical_fingerprint, 'Canonical fingerprint'),
|
|
2892
|
+
occurredAt: requireIsoTimestamp(row.occurred_at, 'Journal occurrence time'),
|
|
2893
|
+
};
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
ensureInitiativeRevisionBaselines(input) {
|
|
2897
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2898
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2899
|
+
const now = requireIsoTimestamp(input.now, 'Initiative baseline time');
|
|
2900
|
+
const initiatives = [...input.initiatives].map((initiative) => ({
|
|
2901
|
+
...initiative,
|
|
2902
|
+
entityId: requireText(initiative.entityId, 'Initiative id'),
|
|
2903
|
+
currentFingerprint: requireText(initiative.currentFingerprint, 'Initiative fingerprint'),
|
|
2904
|
+
occurredAt: requireIsoTimestamp(initiative.occurredAt, 'Initiative occurrence time'),
|
|
2905
|
+
})).sort((left, right) => compareCodeUnits(left.entityId, right.entityId));
|
|
2906
|
+
if (new Set(initiatives.map((initiative) => initiative.entityId)).size !== initiatives.length) {
|
|
2907
|
+
throw new Error('Initiative baseline contains duplicate entity ids.');
|
|
2908
|
+
}
|
|
2909
|
+
for (const initiative of initiatives) {
|
|
2910
|
+
if (!/^[a-f0-9]{64}$/.test(initiative.currentFingerprint)) {
|
|
2911
|
+
throw new Error(`Initiative baseline fingerprint is invalid: ${initiative.entityId}.`);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
return this.db.transaction(() => {
|
|
2915
|
+
this.allocateSequenceRange(tenantId, workspaceId, now, 0);
|
|
2916
|
+
const revisionRows = this.db.prepare(`
|
|
2917
|
+
SELECT entity_id, contract_version, canonical_fingerprint,
|
|
2918
|
+
CAST(entity_revision AS TEXT) AS entity_revision,
|
|
2919
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
2920
|
+
CAST(last_sequence AS TEXT) AS last_sequence
|
|
2921
|
+
FROM workspace_sync_entity_revisions
|
|
2922
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = 'initiative'
|
|
2923
|
+
`).all(tenantId, workspaceId);
|
|
2924
|
+
const revisions = new Map(revisionRows.map((row) => [requireText(row.entity_id, 'Initiative id'), row]));
|
|
2925
|
+
const missing = [];
|
|
2926
|
+
for (const initiative of initiatives) {
|
|
2927
|
+
const current = revisions.get(initiative.entityId);
|
|
2928
|
+
if (!current) {
|
|
2929
|
+
missing.push(initiative);
|
|
2930
|
+
continue;
|
|
2931
|
+
}
|
|
2932
|
+
if (Number(current.contract_version) !== WORKSPACE_SYNC_CONTRACT_VERSION
|
|
2933
|
+
|| current.canonical_fingerprint !== initiative.currentFingerprint
|
|
2934
|
+
|| BigInt(normalizeSequence(current.entity_revision)) < 1n
|
|
2935
|
+
|| (initiative.archived && BigInt(normalizeSequence(current.lifecycle_revision)) < 1n)
|
|
2936
|
+
|| BigInt(normalizeSequence(current.last_sequence)) < 1n) {
|
|
2937
|
+
throw new WorkspaceSyncRevisionConflictError();
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
const rowCount = missing.reduce((count, initiative) => count + (initiative.archived ? 2 : 1), 0);
|
|
2941
|
+
const highWatermark = this.allocateSequenceRange(tenantId, workspaceId, now, rowCount);
|
|
2942
|
+
let sequence = BigInt(highWatermark) - BigInt(rowCount) + 1n;
|
|
2943
|
+
for (const initiative of missing) {
|
|
2944
|
+
const operationId = `initiative-baseline-${randomUUID()}`;
|
|
2945
|
+
const insertJournal = (mutationKind, entityRevision, lifecycleRevision, payload) => {
|
|
2946
|
+
this.db.prepare(`
|
|
2947
|
+
INSERT INTO workspace_sync_journal (
|
|
2948
|
+
tenant_id, workspace_id, sequence, contract_version, operation_id,
|
|
2949
|
+
client_id, domain, entity_id, mutation_kind, entity_revision,
|
|
2950
|
+
lifecycle_revision, content_sha256, size_bytes, payload_json, occurred_at
|
|
2951
|
+
) VALUES (?, ?, ?, ?, ?, 'sync-v3-repair', 'initiative', ?, ?, ?, ?, NULL, NULL, ?, ?)
|
|
2952
|
+
`).run(tenantId, workspaceId, sequence.toString(10), WORKSPACE_SYNC_CONTRACT_VERSION, operationId, initiative.entityId, mutationKind, entityRevision, lifecycleRevision, canonicalWorkspaceSyncJson(payload), initiative.occurredAt);
|
|
2953
|
+
sequence += 1n;
|
|
2954
|
+
};
|
|
2955
|
+
insertJournal('create', 1, 0, initiative.activePayload);
|
|
2956
|
+
if (initiative.archived)
|
|
2957
|
+
insertJournal('archive', 2, 1, initiative.currentPayload);
|
|
2958
|
+
const entityRevision = initiative.archived ? 2 : 1;
|
|
2959
|
+
const lifecycleRevision = initiative.archived ? 1 : 0;
|
|
2960
|
+
const lastSequence = sequence - 1n;
|
|
2961
|
+
this.db.prepare(`
|
|
2962
|
+
INSERT INTO workspace_sync_entity_revisions (
|
|
2963
|
+
tenant_id, workspace_id, domain, entity_id, contract_version,
|
|
2964
|
+
entity_revision, lifecycle_revision, coexistence_owned, metadata_owned, reopen_assignee_owned,
|
|
2965
|
+
last_sequence, canonical_fingerprint, updated_at
|
|
2966
|
+
) VALUES (?, ?, 'initiative', ?, ?, ?, ?, 0, 0, 0, ?, ?, ?)
|
|
2967
|
+
`).run(tenantId, workspaceId, initiative.entityId, WORKSPACE_SYNC_CONTRACT_VERSION, entityRevision, lifecycleRevision, lastSequence.toString(10), initiative.currentFingerprint, now);
|
|
2968
|
+
}
|
|
2969
|
+
return { highWatermark, insertedEntities: missing.length, insertedJournalRows: rowCount };
|
|
2970
|
+
})();
|
|
2971
|
+
}
|
|
2972
|
+
markTaskTaxonomyProjectionOwned(input) {
|
|
2973
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2974
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2975
|
+
const now = requireIsoTimestamp(input.now, 'Taxonomy ownership time');
|
|
2976
|
+
const taskIds = Array.from(new Set(input.taskIds.map((taskId) => requireText(taskId, 'Task id')))).sort();
|
|
2977
|
+
const insert = this.db.prepare(`
|
|
2978
|
+
INSERT INTO workspace_sync_task_taxonomy_ownership (
|
|
2979
|
+
tenant_id, workspace_id, task_id, created_at
|
|
2980
|
+
) VALUES (?, ?, ?, ?)
|
|
2981
|
+
ON CONFLICT (tenant_id, workspace_id, task_id) DO NOTHING
|
|
2982
|
+
`);
|
|
2983
|
+
for (const taskId of taskIds)
|
|
2984
|
+
insert.run(tenantId, workspaceId, taskId, now);
|
|
2985
|
+
}
|
|
2986
|
+
listTaskTaxonomyProjectionOwnership(input) {
|
|
2987
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
2988
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
2989
|
+
const taskIds = Array.from(new Set(input.taskIds.map((taskId) => requireText(taskId, 'Task id')))).sort();
|
|
2990
|
+
if (taskIds.length === 0)
|
|
2991
|
+
return [];
|
|
2992
|
+
const placeholders = taskIds.map(() => '?').join(', ');
|
|
2993
|
+
const rows = this.db.prepare(`
|
|
2994
|
+
WITH ownership AS (
|
|
2995
|
+
SELECT task_id, 0 AS active_operation, 1 AS owned
|
|
2996
|
+
FROM workspace_sync_task_taxonomy_ownership
|
|
2997
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
2998
|
+
AND task_id IN (${placeholders})
|
|
2999
|
+
UNION ALL
|
|
3000
|
+
SELECT target.entity_id AS task_id, 1 AS active_operation, 0 AS owned
|
|
3001
|
+
FROM workspace_sync_outbox_targets AS target
|
|
3002
|
+
INNER JOIN workspace_sync_outbox AS outbox
|
|
3003
|
+
ON outbox.tenant_id = target.tenant_id
|
|
3004
|
+
AND outbox.workspace_id = target.workspace_id
|
|
3005
|
+
AND outbox.client_id = target.client_id
|
|
3006
|
+
AND outbox.operation_id = target.operation_id
|
|
3007
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
3008
|
+
AND outbox.contract_version = ?
|
|
3009
|
+
AND outbox.status IN ('pending', 'in_flight', 'blocked')
|
|
3010
|
+
AND target.domain = 'task' AND target.mutation_kind = 'taxonomy-remap'
|
|
3011
|
+
AND target.entity_id IN (${placeholders})
|
|
3012
|
+
)
|
|
3013
|
+
SELECT task_id, MAX(active_operation) AS active_operation, MAX(owned) AS owned
|
|
3014
|
+
FROM ownership
|
|
3015
|
+
GROUP BY task_id
|
|
3016
|
+
ORDER BY task_id
|
|
3017
|
+
`).all(tenantId, workspaceId, ...taskIds, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, ...taskIds);
|
|
3018
|
+
return rows.map((row) => ({
|
|
3019
|
+
taskId: requireText(row.task_id, 'Task id'),
|
|
3020
|
+
activeOperation: Number(row.active_operation) === 1,
|
|
3021
|
+
owned: Number(row.owned) === 1,
|
|
3022
|
+
}));
|
|
3023
|
+
}
|
|
3024
|
+
listOutboxTargets(identity) {
|
|
3025
|
+
const ids = operationParams(identity);
|
|
3026
|
+
const rows = this.db.prepare(`
|
|
3027
|
+
SELECT domain, entity_id, mutation_kind,
|
|
3028
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
3029
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
3030
|
+
owns_task_metadata, owns_task_lifecycle, owns_task_assignee
|
|
3031
|
+
FROM workspace_sync_outbox_targets
|
|
3032
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3033
|
+
ORDER BY domain, entity_id
|
|
3034
|
+
`).all(...ids);
|
|
3035
|
+
return rows.map((row) => ({
|
|
3036
|
+
domain: requireText(row.domain, 'Outbox target domain'),
|
|
3037
|
+
entityId: requireText(row.entity_id, 'Outbox target entity id'),
|
|
3038
|
+
mutationKind: requireText(row.mutation_kind, 'Outbox target mutation kind'),
|
|
3039
|
+
baseEntityRevision: normalizeSequence(row.base_entity_revision),
|
|
3040
|
+
baseLifecycleRevision: normalizeSequence(row.base_lifecycle_revision),
|
|
3041
|
+
ownsTaskMetadata: Number(row.owns_task_metadata) === 1,
|
|
3042
|
+
ownsTaskLifecycle: Number(row.owns_task_lifecycle) === 1,
|
|
3043
|
+
ownsTaskAssignee: Number(row.owns_task_assignee) === 1,
|
|
3044
|
+
}));
|
|
3045
|
+
}
|
|
3046
|
+
runTaskMetadataCoexistenceTransaction(input) {
|
|
3047
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3048
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3049
|
+
const taskIds = Array.from(new Set(input.taskIds.map((taskId) => requireText(taskId, 'Task id')))).sort();
|
|
3050
|
+
return this.runWorkspaceDomainCoexistenceTransaction({
|
|
3051
|
+
tenantId,
|
|
3052
|
+
workspaceId,
|
|
3053
|
+
direction: input.direction,
|
|
3054
|
+
targets: taskIds.map((entityId) => ({ domain: 'task', entityId })),
|
|
3055
|
+
now: input.now,
|
|
3056
|
+
apply: () => input.apply(this.listTaskMetadataOwnership({ tenantId, workspaceId })),
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
runWorkspaceDomainCoexistenceTransaction(input) {
|
|
3060
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3061
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3062
|
+
const now = requireIsoTimestamp(input.now, 'Coexistence lock timestamp');
|
|
3063
|
+
const targets = normalizeWorkspaceSyncLockTargets(input.targets);
|
|
3064
|
+
const localTargets = normalizeWorkspaceSyncLockTargets([
|
|
3065
|
+
...targets,
|
|
3066
|
+
...(targets.some((target) => target.domain === 'initiative') ? [
|
|
3067
|
+
{ domain: 'initiative-assets', entityId: '__workspace__' },
|
|
3068
|
+
{ domain: 'initiative-owners', entityId: '__workspace__' },
|
|
3069
|
+
] : []),
|
|
3070
|
+
...(targets.some((target) => target.domain === 'workstream') ? [
|
|
3071
|
+
{ domain: 'workstream-assets', entityId: '__workspace__' },
|
|
3072
|
+
{ domain: 'workstream-owners', entityId: '__workspace__' },
|
|
3073
|
+
] : []),
|
|
3074
|
+
]);
|
|
3075
|
+
return this.db.transaction(() => {
|
|
3076
|
+
if (input.direction === 'cloud-push') {
|
|
3077
|
+
// Cloud acceptance uses this row as the workspace-wide journal
|
|
3078
|
+
// serialization lock. A zero-width allocation acquires the same
|
|
3079
|
+
// lock without consuming a sequence.
|
|
3080
|
+
this.allocateSequenceRange(tenantId, workspaceId, now, 0);
|
|
3081
|
+
}
|
|
3082
|
+
else {
|
|
3083
|
+
// Local capture locks the same per-entity rows. Stable ordering
|
|
3084
|
+
// avoids deadlocks when one legacy page contains several domains.
|
|
3085
|
+
const lock = this.db.prepare(`
|
|
3086
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
3087
|
+
VALUES (?, ?, ?, ?)
|
|
3088
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
3089
|
+
`);
|
|
3090
|
+
if (requiresInitiativeProjectionLock(targets)) {
|
|
3091
|
+
lock.run(tenantId, workspaceId, WORKSPACE_SYNC_INITIATIVE_PROJECTION_LOCK, now);
|
|
3092
|
+
}
|
|
3093
|
+
if (requiresCombinedProjectionLock(targets)) {
|
|
3094
|
+
lock.run(tenantId, workspaceId, WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
3095
|
+
}
|
|
3096
|
+
for (const target of localTargets) {
|
|
3097
|
+
lock.run(tenantId, workspaceId, `local-outbox:${target.domain}:${target.entityId}`, now);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
return input.apply();
|
|
3101
|
+
})();
|
|
3102
|
+
}
|
|
3103
|
+
requeueLegacyPlanningAttachmentAcknowledgements(input) {
|
|
3104
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3105
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3106
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3107
|
+
const result = this.db.prepare(`
|
|
3108
|
+
UPDATE workspace_sync_outbox
|
|
3109
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3110
|
+
last_error_code = 'WORKSPACE_SYNC_PLANNING_ATTACHMENT_ACK_RECOVERY',
|
|
3111
|
+
last_error_message = 'Requeued after the compact planning attachment activity contract was repaired.',
|
|
3112
|
+
updated_at = ?
|
|
3113
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3114
|
+
AND mutation_kind = 'metadata-update'
|
|
3115
|
+
AND last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3116
|
+
AND (
|
|
3117
|
+
(domain = 'initiative' AND last_error_message = 'Initiative attachment link semantics are invalid.')
|
|
3118
|
+
OR (domain = 'workstream' AND last_error_message = 'Workstream attachment link semantics are invalid.')
|
|
3119
|
+
)
|
|
3120
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3121
|
+
return Number(result.changes || 0);
|
|
3122
|
+
}
|
|
3123
|
+
requeueSupersededTaskAttachmentReferenceMetadataConflicts(input) {
|
|
3124
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3125
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3126
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3127
|
+
const result = this.db.prepare(`
|
|
3128
|
+
UPDATE workspace_sync_outbox
|
|
3129
|
+
SET status = 'pending', attempt_count = 0, rebase_before_first_attempt = 1,
|
|
3130
|
+
next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3131
|
+
task_attachment_reference_metadata_recovery_count =
|
|
3132
|
+
task_attachment_reference_metadata_recovery_count + 1,
|
|
3133
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_ATTACHMENT_REFERENCE_METADATA_RECOVERY',
|
|
3134
|
+
last_error_message = 'Requeued against the current attachment-link revision after cloud writers began canonicalizing reference metadata by stable asset identity.',
|
|
3135
|
+
updated_at = ?
|
|
3136
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3137
|
+
AND domain = 'task-attachment-links' AND mutation_kind = 'replace'
|
|
3138
|
+
AND (
|
|
3139
|
+
(
|
|
3140
|
+
task_attachment_reference_metadata_recovery_count = 0
|
|
3141
|
+
AND last_error_code = 'TASK_ATTACHMENT_LINKS_INVALID'
|
|
3142
|
+
AND last_error_message LIKE 'Task attachment reference metadata does not match asset %.%'
|
|
3143
|
+
)
|
|
3144
|
+
OR (
|
|
3145
|
+
task_attachment_reference_metadata_recovery_count = 1
|
|
3146
|
+
AND rebase_before_first_attempt = 0
|
|
3147
|
+
AND last_error_code = 'WORKSPACE_SYNC_REVISION_CONFLICT'
|
|
3148
|
+
)
|
|
3149
|
+
)
|
|
3150
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3151
|
+
return Number(result.changes || 0);
|
|
3152
|
+
}
|
|
3153
|
+
requeueSupersededOriginDispatchAuthorizationFailures(input) {
|
|
3154
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3155
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3156
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3157
|
+
const result = this.db.prepare(`
|
|
3158
|
+
UPDATE workspace_sync_outbox
|
|
3159
|
+
SET status = 'pending', next_attempt_at = ?,
|
|
3160
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
3161
|
+
origin_assertion_recovery_count = origin_assertion_recovery_count + 1,
|
|
3162
|
+
last_error_code = 'WORKSPACE_SYNC_ORIGIN_ASSERTION_UPGRADE_RECOVERY',
|
|
3163
|
+
last_error_message = 'Requeued after signed origin assertions replaced the legacy local-dispatch trust header.',
|
|
3164
|
+
updated_at = ?
|
|
3165
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
3166
|
+
AND status = 'blocked'
|
|
3167
|
+
AND origin_assertion_recovery_count = 0
|
|
3168
|
+
AND last_error_code = 'WORKSPACE_SYNC_V3_REQUEST_INVALID'
|
|
3169
|
+
AND last_error_message = 'originActor is only accepted from trusted local durable dispatch.'
|
|
3170
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3171
|
+
return Number(result.changes || 0);
|
|
3172
|
+
}
|
|
3173
|
+
requeueSupersededTaskCreateChecklistAcknowledgements(input) {
|
|
3174
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3175
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3176
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3177
|
+
const result = this.db.prepare(`
|
|
3178
|
+
UPDATE workspace_sync_outbox
|
|
3179
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3180
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_CREATE_CHECKLIST_ACK_RECOVERY',
|
|
3181
|
+
last_error_message = 'Requeued after task create acknowledgement began checking the captured checklist.',
|
|
3182
|
+
updated_at = ?
|
|
3183
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3184
|
+
AND domain = 'task' AND mutation_kind = 'create'
|
|
3185
|
+
AND last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3186
|
+
AND last_error_message = 'Authoritative task create response omitted the captured checklist projection.'
|
|
3187
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3188
|
+
return Number(result.changes || 0);
|
|
3189
|
+
}
|
|
3190
|
+
requeueInitialTaskCreateRouteMisses(input) {
|
|
3191
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3192
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3193
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3194
|
+
const result = this.db.prepare(`
|
|
3195
|
+
UPDATE workspace_sync_outbox
|
|
3196
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3197
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_CREATE_ROUTE_RECOVERY',
|
|
3198
|
+
last_error_message = 'Requeued once after the initial task create route was unavailable.',
|
|
3199
|
+
updated_at = ?
|
|
3200
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3201
|
+
AND domain = 'task' AND mutation_kind = 'create'
|
|
3202
|
+
AND attempt_count = 1
|
|
3203
|
+
AND last_error_code = 'WORKSPACE_SYNC_TRANSPORT_404'
|
|
3204
|
+
AND last_error_message = 'Not Found'
|
|
3205
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3206
|
+
return Number(result.changes || 0);
|
|
3207
|
+
}
|
|
3208
|
+
requeueSupersededTaskCommentReconciliationConflicts(input) {
|
|
3209
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3210
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3211
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3212
|
+
const result = this.db.prepare(`
|
|
3213
|
+
UPDATE workspace_sync_outbox
|
|
3214
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3215
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_COMMENT_RECONCILIATION_RECOVERY',
|
|
3216
|
+
last_error_message = 'Requeued once after task-comment reconciliation became revision-independent.',
|
|
3217
|
+
updated_at = ?
|
|
3218
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3219
|
+
AND domain = 'task-comments' AND mutation_kind = 'reconcile'
|
|
3220
|
+
AND attempt_count = 1
|
|
3221
|
+
AND last_error_code = 'WORKSPACE_SYNC_REVISION_CONFLICT'
|
|
3222
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3223
|
+
return Number(result.changes || 0);
|
|
3224
|
+
}
|
|
3225
|
+
requeueTaskCommentRootAcknowledgementConflicts(input) {
|
|
3226
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3227
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3228
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3229
|
+
const result = this.db.prepare(`
|
|
3230
|
+
UPDATE workspace_sync_outbox
|
|
3231
|
+
SET status = 'pending', next_attempt_at = ?,
|
|
3232
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
3233
|
+
accepted_replay_recovery_count = accepted_replay_recovery_count + 1,
|
|
3234
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_COMMENT_ROOT_ACK_RECOVERY',
|
|
3235
|
+
last_error_message = 'Requeued once after task-comment acknowledgement stopped reinstalling unrelated task-root metadata.',
|
|
3236
|
+
updated_at = ?
|
|
3237
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
3238
|
+
AND contract_version = ?
|
|
3239
|
+
AND status = 'blocked'
|
|
3240
|
+
AND domain = 'task-comments' AND mutation_kind = 'append'
|
|
3241
|
+
AND last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3242
|
+
AND last_error_message = 'Authoritative task root differs outside the comment mutation.'
|
|
3243
|
+
AND accepted_replay_recovery_count = 1
|
|
3244
|
+
AND recovery_batch_id IS NULL
|
|
3245
|
+
`).run(now, now, tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION);
|
|
3246
|
+
return Number(result.changes || 0);
|
|
3247
|
+
}
|
|
3248
|
+
requeueBoundedTaskStatusRevisionConflicts(input) {
|
|
3249
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3250
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3251
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3252
|
+
const result = this.db.prepare(`
|
|
3253
|
+
UPDATE workspace_sync_outbox
|
|
3254
|
+
SET status = 'pending', attempt_count = 0, rebase_before_first_attempt = 1,
|
|
3255
|
+
next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3256
|
+
task_status_revision_rebase_count = task_status_revision_rebase_count + 1,
|
|
3257
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_STATUS_REVISION_REBASE',
|
|
3258
|
+
last_error_message = 'Requeued once against the current task revision after an initial conflict.',
|
|
3259
|
+
updated_at = ?
|
|
3260
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3261
|
+
AND domain = 'task' AND mutation_kind = 'set-status'
|
|
3262
|
+
AND attempt_count = 1
|
|
3263
|
+
AND task_status_revision_rebase_count < 1
|
|
3264
|
+
AND last_error_code = 'WORKSPACE_SYNC_REVISION_CONFLICT'
|
|
3265
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3266
|
+
return Number(result.changes || 0);
|
|
3267
|
+
}
|
|
3268
|
+
// Workstream/initiative attachment-only metadata patches are Partial<>
|
|
3269
|
+
// merges, not full snapshots. The cloud accepts those patches against its
|
|
3270
|
+
// current revision, so this bounded retry can preserve unrelated concurrent
|
|
3271
|
+
// metadata. Other metadata patches remain exact-revision operations: this
|
|
3272
|
+
// selector may retry one once, but the cloud will reject it again rather
|
|
3273
|
+
// than allowing a stale title or structural edit to overwrite newer state.
|
|
3274
|
+
requeueBoundedWorkstreamMetadataRevisionConflicts(input) {
|
|
3275
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3276
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3277
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3278
|
+
const result = this.db.prepare(`
|
|
3279
|
+
UPDATE workspace_sync_outbox
|
|
3280
|
+
SET status = 'pending', attempt_count = 0, rebase_before_first_attempt = 1,
|
|
3281
|
+
next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3282
|
+
workstream_metadata_revision_rebase_count = workstream_metadata_revision_rebase_count + 1,
|
|
3283
|
+
last_error_code = 'WORKSPACE_SYNC_WORKSTREAM_METADATA_REVISION_REBASE',
|
|
3284
|
+
last_error_message = 'Requeued once against the current workstream revision after an initial conflict.',
|
|
3285
|
+
updated_at = ?
|
|
3286
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3287
|
+
AND domain = 'workstream' AND mutation_kind = 'metadata-update'
|
|
3288
|
+
AND attempt_count = 1
|
|
3289
|
+
AND workstream_metadata_revision_rebase_count < 1
|
|
3290
|
+
AND last_error_code = 'WORKSPACE_SYNC_REVISION_CONFLICT'
|
|
3291
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3292
|
+
return Number(result.changes || 0);
|
|
3293
|
+
}
|
|
3294
|
+
requeueBoundedInitiativeMetadataRevisionConflicts(input) {
|
|
3295
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3296
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3297
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3298
|
+
const result = this.db.prepare(`
|
|
3299
|
+
UPDATE workspace_sync_outbox
|
|
3300
|
+
SET status = 'pending', attempt_count = 0, rebase_before_first_attempt = 1,
|
|
3301
|
+
next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3302
|
+
initiative_metadata_revision_rebase_count = initiative_metadata_revision_rebase_count + 1,
|
|
3303
|
+
last_error_code = 'WORKSPACE_SYNC_INITIATIVE_METADATA_REVISION_REBASE',
|
|
3304
|
+
last_error_message = 'Requeued once against the current initiative revision after an initial conflict.',
|
|
3305
|
+
updated_at = ?
|
|
3306
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3307
|
+
AND domain = 'initiative' AND mutation_kind = 'metadata-update'
|
|
3308
|
+
AND attempt_count = 1
|
|
3309
|
+
AND initiative_metadata_revision_rebase_count < 1
|
|
3310
|
+
AND last_error_code = 'WORKSPACE_SYNC_REVISION_CONFLICT'
|
|
3311
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3312
|
+
return Number(result.changes || 0);
|
|
3313
|
+
}
|
|
3314
|
+
requeueTaskCommentProvenanceReconciliationConflicts(input) {
|
|
3315
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3316
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3317
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3318
|
+
const result = this.db.prepare(`
|
|
3319
|
+
UPDATE workspace_sync_outbox
|
|
3320
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3321
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_COMMENT_PROVENANCE_RECOVERY',
|
|
3322
|
+
last_error_message = 'Requeued once after reconciliation began adopting authoritative comment provenance.',
|
|
3323
|
+
updated_at = ?
|
|
3324
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3325
|
+
AND domain = 'task-comments' AND mutation_kind = 'reconcile'
|
|
3326
|
+
AND attempt_count = 1
|
|
3327
|
+
AND (
|
|
3328
|
+
(
|
|
3329
|
+
last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3330
|
+
AND last_error_message LIKE 'Task comment identity conflict:%'
|
|
3331
|
+
)
|
|
3332
|
+
OR (
|
|
3333
|
+
last_error_code = 'TASK_COMMENT_IDENTITY_CONFLICT'
|
|
3334
|
+
AND last_error_message = 'Task comment identity conflict: reconciliation cannot change an authoritative comment.'
|
|
3335
|
+
)
|
|
3336
|
+
)
|
|
3337
|
+
`).run(now, now, tenantId, workspaceId);
|
|
3338
|
+
return Number(result.changes || 0);
|
|
3339
|
+
}
|
|
3340
|
+
supersedeArchivedReadOnlyTaskRecoveryIntents(input) {
|
|
3341
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3342
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3343
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3344
|
+
return this.db.transaction(() => {
|
|
3345
|
+
const rows = this.db.prepare(`
|
|
3346
|
+
SELECT client_id, operation_id, entity_id, mutation_kind,
|
|
3347
|
+
payload_json, recovery_batch_id
|
|
3348
|
+
FROM workspace_sync_outbox
|
|
3349
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
3350
|
+
AND status = 'blocked'
|
|
3351
|
+
AND recovery_batch_id IS NOT NULL
|
|
3352
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3353
|
+
AND domain = 'task'
|
|
3354
|
+
AND last_error_code = 'TASK_ARCHIVED_READ_ONLY'
|
|
3355
|
+
ORDER BY enqueue_sequence, created_at, operation_id
|
|
3356
|
+
${this.db.provider === 'postgres' ? 'FOR UPDATE' : ''}
|
|
3357
|
+
`).all(tenantId, workspaceId);
|
|
3358
|
+
let superseded = 0;
|
|
3359
|
+
const affectedRecoveryBatchIds = new Set();
|
|
3360
|
+
const supersededEntities = new Set();
|
|
3361
|
+
for (const row of rows) {
|
|
3362
|
+
const taskId = requireText(row.entity_id, 'Task id');
|
|
3363
|
+
if (!input.isTaskArchived(taskId))
|
|
3364
|
+
continue;
|
|
3365
|
+
const recoveryBatchId = requireText(row.recovery_batch_id, 'Recovery batch id');
|
|
3366
|
+
const recoveryEntityKey = `${recoveryBatchId}\u0000${taskId}`;
|
|
3367
|
+
if (!supersededEntities.has(recoveryEntityKey)) {
|
|
3368
|
+
const lifecyclePrecedence = this.db.prepare(`
|
|
3369
|
+
SELECT current.lifecycle_revision,
|
|
3370
|
+
COUNT(*) AS unsettled_count,
|
|
3371
|
+
SUM(CASE WHEN replay.status = 'in_flight' THEN 1 ELSE 0 END)
|
|
3372
|
+
AS in_flight_count,
|
|
3373
|
+
SUM(CASE
|
|
3374
|
+
WHEN source.base_lifecycle_revision < current.lifecycle_revision
|
|
3375
|
+
THEN 0 ELSE 1
|
|
3376
|
+
END) AS not_predating_count
|
|
3377
|
+
FROM workspace_sync_outbox AS replay
|
|
3378
|
+
INNER JOIN workspace_sync_outbox AS source
|
|
3379
|
+
ON source.tenant_id = replay.tenant_id
|
|
3380
|
+
AND source.workspace_id = replay.workspace_id
|
|
3381
|
+
AND source.client_id = replay.replay_of_client_id
|
|
3382
|
+
AND source.operation_id = replay.replay_of_operation_id
|
|
3383
|
+
INNER JOIN workspace_sync_entity_revisions AS current
|
|
3384
|
+
ON current.tenant_id = replay.tenant_id
|
|
3385
|
+
AND current.workspace_id = replay.workspace_id
|
|
3386
|
+
AND current.domain = replay.domain
|
|
3387
|
+
AND current.entity_id = replay.entity_id
|
|
3388
|
+
WHERE replay.tenant_id = ? AND replay.workspace_id = ?
|
|
3389
|
+
AND replay.recovery_batch_id = ?
|
|
3390
|
+
AND replay.domain = 'task' AND replay.entity_id = ?
|
|
3391
|
+
AND replay.replay_of_operation_id IS NOT NULL
|
|
3392
|
+
AND replay.status IN ('pending', 'blocked', 'in_flight')
|
|
3393
|
+
GROUP BY current.lifecycle_revision
|
|
3394
|
+
`).get(tenantId, workspaceId, recoveryBatchId, taskId);
|
|
3395
|
+
if (Number(lifecyclePrecedence?.lifecycle_revision || 0) > 0
|
|
3396
|
+
&& Number(lifecyclePrecedence?.unsettled_count || 0) > 0
|
|
3397
|
+
&& Number(lifecyclePrecedence?.in_flight_count || 0) === 0
|
|
3398
|
+
&& Number(lifecyclePrecedence?.not_predating_count || 0) === 0) {
|
|
3399
|
+
const updated = this.db.prepare(`
|
|
3400
|
+
UPDATE workspace_sync_outbox
|
|
3401
|
+
SET status = 'superseded',
|
|
3402
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
3403
|
+
superseded_at = ?,
|
|
3404
|
+
last_error_code = 'WORKSPACE_SYNC_RECOVERY_INTENT_SUPERSEDED_BY_ARCHIVE',
|
|
3405
|
+
last_error_message = 'Recovered task intent was superseded because a newer authoritative archive lifecycle revision exists.',
|
|
3406
|
+
updated_at = ?
|
|
3407
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
3408
|
+
AND recovery_batch_id = ?
|
|
3409
|
+
AND domain = 'task' AND entity_id = ?
|
|
3410
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3411
|
+
AND status IN ('pending', 'blocked')
|
|
3412
|
+
`).run(now, now, tenantId, workspaceId, recoveryBatchId, taskId);
|
|
3413
|
+
const updatedCount = Number(updated.changes || 0);
|
|
3414
|
+
if (updatedCount > 0) {
|
|
3415
|
+
superseded += updatedCount;
|
|
3416
|
+
supersededEntities.add(recoveryEntityKey);
|
|
3417
|
+
affectedRecoveryBatchIds.add(recoveryBatchId);
|
|
3418
|
+
continue;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
if (row.mutation_kind !== 'metadata-update')
|
|
3423
|
+
continue;
|
|
3424
|
+
let payload;
|
|
3425
|
+
try {
|
|
3426
|
+
payload = JSON.parse(String(row.payload_json || ''));
|
|
3427
|
+
}
|
|
3428
|
+
catch {
|
|
3429
|
+
continue;
|
|
3430
|
+
}
|
|
3431
|
+
const patch = payload.patch;
|
|
3432
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch))
|
|
3433
|
+
continue;
|
|
3434
|
+
const patchEntries = Object.entries(patch);
|
|
3435
|
+
const assignee = patchEntries[0]?.[1];
|
|
3436
|
+
if (patchEntries.length !== 1
|
|
3437
|
+
|| patchEntries[0][0] !== 'assignee'
|
|
3438
|
+
|| typeof assignee !== 'string'
|
|
3439
|
+
|| !assignee.trim()) {
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
const updated = this.db.prepare(`
|
|
3443
|
+
UPDATE workspace_sync_outbox
|
|
3444
|
+
SET status = 'superseded',
|
|
3445
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
3446
|
+
superseded_at = ?,
|
|
3447
|
+
last_error_code = 'WORKSPACE_SYNC_RECOVERY_INTENT_SUPERSEDED_BY_ARCHIVE',
|
|
3448
|
+
last_error_message = 'Assignee-only recovery intent was superseded because the task is authoritatively archived.',
|
|
3449
|
+
updated_at = ?
|
|
3450
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
3451
|
+
AND client_id = ? AND operation_id = ?
|
|
3452
|
+
AND status = 'blocked'
|
|
3453
|
+
AND last_error_code = 'TASK_ARCHIVED_READ_ONLY'
|
|
3454
|
+
`).run(now, now, tenantId, workspaceId, requireText(row.client_id, 'Client id'), requireText(row.operation_id, 'Operation id'));
|
|
3455
|
+
if (Number(updated.changes || 0) === 1) {
|
|
3456
|
+
superseded += 1;
|
|
3457
|
+
affectedRecoveryBatchIds.add(recoveryBatchId);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
for (const recoveryBatchId of affectedRecoveryBatchIds) {
|
|
3461
|
+
const unsettled = this.db.prepare(`
|
|
3462
|
+
SELECT COUNT(*) AS count
|
|
3463
|
+
FROM workspace_sync_outbox
|
|
3464
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
3465
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3466
|
+
AND status NOT IN ('accepted', 'superseded')
|
|
3467
|
+
`).get(tenantId, workspaceId, recoveryBatchId);
|
|
3468
|
+
if (Number(unsettled.count || 0) !== 0)
|
|
3469
|
+
continue;
|
|
3470
|
+
this.db.prepare(`
|
|
3471
|
+
UPDATE workspace_sync_outbox_recoveries
|
|
3472
|
+
SET state = 'completed', completed_at = ?, updated_at = ?
|
|
3473
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
3474
|
+
AND state = 'replay_queued'
|
|
3475
|
+
AND EXISTS (
|
|
3476
|
+
SELECT 1
|
|
3477
|
+
FROM workspace_sync_outbox
|
|
3478
|
+
WHERE tenant_id = ? AND workspace_id = ? AND recovery_batch_id = ?
|
|
3479
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3480
|
+
AND status = 'superseded'
|
|
3481
|
+
AND last_error_code = 'WORKSPACE_SYNC_RECOVERY_INTENT_SUPERSEDED_BY_ARCHIVE'
|
|
3482
|
+
)
|
|
3483
|
+
`).run(now, now, tenantId, workspaceId, recoveryBatchId, tenantId, workspaceId, recoveryBatchId);
|
|
3484
|
+
}
|
|
3485
|
+
return superseded;
|
|
3486
|
+
})();
|
|
3487
|
+
}
|
|
3488
|
+
requeueSupersededTaskStatusWorkflowAcknowledgements(input) {
|
|
3489
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3490
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3491
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3492
|
+
return this.db.transaction(() => {
|
|
3493
|
+
const candidates = this.db.prepare(`
|
|
3494
|
+
SELECT client_id, operation_id, payload_json
|
|
3495
|
+
FROM workspace_sync_outbox
|
|
3496
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3497
|
+
AND domain = 'task' AND mutation_kind = 'set-status'
|
|
3498
|
+
AND task_status_workflow_ack_recovery_count < 2
|
|
3499
|
+
AND last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3500
|
+
AND last_error_message = 'Authoritative workflow assignment does not match the persisted request.'
|
|
3501
|
+
`).all(tenantId, workspaceId);
|
|
3502
|
+
let recovered = 0;
|
|
3503
|
+
for (const candidate of candidates) {
|
|
3504
|
+
let payload;
|
|
3505
|
+
try {
|
|
3506
|
+
payload = JSON.parse(String(candidate.payload_json || ''));
|
|
3507
|
+
}
|
|
3508
|
+
catch {
|
|
3509
|
+
continue;
|
|
3510
|
+
}
|
|
3511
|
+
if (!['task', 'review', 'done', 'cancelled'].includes(String(payload.status || '')))
|
|
3512
|
+
continue;
|
|
3513
|
+
const result = this.db.prepare(`
|
|
3514
|
+
UPDATE workspace_sync_outbox
|
|
3515
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3516
|
+
task_status_workflow_ack_recovery_count = task_status_workflow_ack_recovery_count + 1,
|
|
3517
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_STATUS_WORKFLOW_ACK_RECOVERY',
|
|
3518
|
+
last_error_message = 'Requeued after existing workflow assignment acknowledgement was repaired.',
|
|
3519
|
+
updated_at = ?
|
|
3520
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3521
|
+
AND status = 'blocked'
|
|
3522
|
+
AND task_status_workflow_ack_recovery_count < 2
|
|
3523
|
+
`).run(now, now, tenantId, workspaceId, requireText(candidate.client_id, 'Client id'), requireText(candidate.operation_id, 'Operation id'));
|
|
3524
|
+
recovered += Number(result.changes || 0);
|
|
3525
|
+
}
|
|
3526
|
+
return recovered;
|
|
3527
|
+
})();
|
|
3528
|
+
}
|
|
3529
|
+
requeueTaskStatusAssigneeAcknowledgements(input) {
|
|
3530
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3531
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3532
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3533
|
+
return this.db.transaction(() => {
|
|
3534
|
+
const candidates = this.db.prepare(`
|
|
3535
|
+
SELECT client_id, operation_id, payload_json
|
|
3536
|
+
FROM workspace_sync_outbox
|
|
3537
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3538
|
+
AND domain = 'task' AND mutation_kind = 'set-status'
|
|
3539
|
+
AND last_error_code = 'WORKSPACE_SYNC_AUTHORITATIVE_RESPONSE_INVALID'
|
|
3540
|
+
AND last_error_message = 'Authoritative task assignee does not match the persisted request.'
|
|
3541
|
+
`).all(tenantId, workspaceId);
|
|
3542
|
+
let recovered = 0;
|
|
3543
|
+
for (const candidate of candidates) {
|
|
3544
|
+
let payload;
|
|
3545
|
+
try {
|
|
3546
|
+
payload = JSON.parse(String(candidate.payload_json || ''));
|
|
3547
|
+
}
|
|
3548
|
+
catch {
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3551
|
+
const assigneeIntent = payload.assigneeIntent;
|
|
3552
|
+
if (assigneeIntent?.kind !== 'set' || !String(assigneeIntent.value || '').trim())
|
|
3553
|
+
continue;
|
|
3554
|
+
const result = this.db.prepare(`
|
|
3555
|
+
UPDATE workspace_sync_outbox
|
|
3556
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3557
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_STATUS_ASSIGNEE_ACK_RECOVERY',
|
|
3558
|
+
last_error_message = 'Requeued after task status acknowledgement began validating the durable assignee intent.',
|
|
3559
|
+
updated_at = ?
|
|
3560
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3561
|
+
AND status = 'blocked'
|
|
3562
|
+
`).run(now, now, tenantId, workspaceId, requireText(candidate.client_id, 'Client id'), requireText(candidate.operation_id, 'Operation id'));
|
|
3563
|
+
recovered += Number(result.changes || 0);
|
|
3564
|
+
}
|
|
3565
|
+
return recovered;
|
|
3566
|
+
})();
|
|
3567
|
+
}
|
|
3568
|
+
requeueTaskStatusWorkflowHistoryAcknowledgements(input) {
|
|
3569
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3570
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3571
|
+
const now = requireIsoTimestamp(input.now, 'Recovery time');
|
|
3572
|
+
return this.db.transaction(() => {
|
|
3573
|
+
const candidates = this.db.prepare(`
|
|
3574
|
+
SELECT client_id, operation_id, payload_json
|
|
3575
|
+
FROM workspace_sync_outbox
|
|
3576
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'blocked'
|
|
3577
|
+
AND domain = 'task' AND mutation_kind = 'set-status'
|
|
3578
|
+
AND recovery_batch_id IS NOT NULL
|
|
3579
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3580
|
+
AND attempt_count = 1
|
|
3581
|
+
AND last_error_code = 'WORKFLOW_ASSIGNMENT_SYNC_CONFLICT'
|
|
3582
|
+
AND last_error_message = 'Workflow assignment history conflicts with canonical storage.'
|
|
3583
|
+
`).all(tenantId, workspaceId);
|
|
3584
|
+
let recovered = 0;
|
|
3585
|
+
for (const candidate of candidates) {
|
|
3586
|
+
let payload;
|
|
3587
|
+
try {
|
|
3588
|
+
payload = JSON.parse(String(candidate.payload_json || ''));
|
|
3589
|
+
}
|
|
3590
|
+
catch {
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
if (payload.workflowAssignment != null)
|
|
3594
|
+
continue;
|
|
3595
|
+
const result = this.db.prepare(`
|
|
3596
|
+
UPDATE workspace_sync_outbox
|
|
3597
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3598
|
+
last_error_code = 'WORKSPACE_SYNC_TASK_STATUS_WORKFLOW_HISTORY_ACK_RECOVERY',
|
|
3599
|
+
last_error_message = 'Requeued after exact-prefix cloud workflow history preservation was enabled.',
|
|
3600
|
+
updated_at = ?
|
|
3601
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3602
|
+
AND status = 'blocked'
|
|
3603
|
+
AND recovery_batch_id IS NOT NULL
|
|
3604
|
+
AND replay_of_operation_id IS NOT NULL
|
|
3605
|
+
AND attempt_count = 1
|
|
3606
|
+
AND last_error_code = 'WORKFLOW_ASSIGNMENT_SYNC_CONFLICT'
|
|
3607
|
+
AND last_error_message = 'Workflow assignment history conflicts with canonical storage.'
|
|
3608
|
+
`).run(now, now, tenantId, workspaceId, requireText(candidate.client_id, 'Client id'), requireText(candidate.operation_id, 'Operation id'));
|
|
3609
|
+
recovered += Number(result.changes || 0);
|
|
3610
|
+
}
|
|
3611
|
+
return recovered;
|
|
3612
|
+
})();
|
|
3613
|
+
}
|
|
3614
|
+
claimOutbox(input) {
|
|
3615
|
+
const tenantId = requireText(input.tenantId, 'Tenant id');
|
|
3616
|
+
const workspaceId = requireText(input.workspaceId, 'Workspace id');
|
|
3617
|
+
const leaseOwner = requireText(input.leaseOwner || randomUUID(), 'Lease owner');
|
|
3618
|
+
const now = requireIsoTimestamp(input.now, 'Claim time');
|
|
3619
|
+
const leaseExpiresAt = requireIsoTimestamp(input.leaseExpiresAt, 'Lease expiry');
|
|
3620
|
+
if (leaseExpiresAt <= now)
|
|
3621
|
+
throw new Error('Lease expiry must be after the claim time.');
|
|
3622
|
+
const limit = Math.max(1, Math.min(100, Math.floor(Number(input.limit) || 0)));
|
|
3623
|
+
const dispatchClasses = (input.dispatchClasses || []).map(requireOutboxDispatchClass);
|
|
3624
|
+
const dispatchClassFilter = dispatchClasses.length > 0
|
|
3625
|
+
? `AND outbox.dispatch_class IN (${dispatchClasses.map(() => '?').join(', ')})`
|
|
3626
|
+
: '';
|
|
3627
|
+
const records = this.db.transaction(() => {
|
|
3628
|
+
const futureDeadline = new Date(Date.parse(now) + MAX_OUTBOX_FUTURE_DEADLINE_MS).toISOString();
|
|
3629
|
+
// A corrected fast local clock must not strand an operation for years.
|
|
3630
|
+
// Legitimate dispatcher and server retry deadlines are capped at 24h.
|
|
3631
|
+
this.db.prepare(`
|
|
3632
|
+
UPDATE workspace_sync_outbox
|
|
3633
|
+
SET next_attempt_at = ?, updated_at = ?,
|
|
3634
|
+
last_error_code = 'WORKSPACE_SYNC_CLOCK_RECOVERY',
|
|
3635
|
+
last_error_message = 'Recovered an implausible future retry deadline after a local clock correction.'
|
|
3636
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'pending'
|
|
3637
|
+
AND next_attempt_at > ?
|
|
3638
|
+
`).run(now, now, tenantId, workspaceId, futureDeadline);
|
|
3639
|
+
this.db.prepare(`
|
|
3640
|
+
UPDATE workspace_sync_outbox
|
|
3641
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
3642
|
+
updated_at = ?, last_error_code = 'WORKSPACE_SYNC_CLOCK_RECOVERY',
|
|
3643
|
+
last_error_message = 'Recovered an implausible future lease after a local clock correction.'
|
|
3644
|
+
WHERE tenant_id = ? AND workspace_id = ? AND status = 'in_flight'
|
|
3645
|
+
AND lease_expires_at > ?
|
|
3646
|
+
`).run(now, now, tenantId, workspaceId, futureDeadline);
|
|
3647
|
+
const candidates = this.db.prepare(`
|
|
3648
|
+
SELECT outbox.client_id, outbox.operation_id
|
|
3649
|
+
FROM workspace_sync_outbox AS outbox
|
|
3650
|
+
WHERE outbox.tenant_id = ? AND outbox.workspace_id = ?
|
|
3651
|
+
AND ((outbox.status = 'pending' AND outbox.next_attempt_at <= ?)
|
|
3652
|
+
OR (outbox.status = 'in_flight' AND outbox.lease_expires_at <= ?))
|
|
3653
|
+
${dispatchClassFilter}
|
|
3654
|
+
AND NOT EXISTS (
|
|
3655
|
+
SELECT 1 FROM workspace_sync_outbox AS earlier
|
|
3656
|
+
WHERE earlier.tenant_id = outbox.tenant_id
|
|
3657
|
+
AND earlier.workspace_id = outbox.workspace_id
|
|
3658
|
+
AND earlier.status IN ('pending', 'in_flight', 'blocked')
|
|
3659
|
+
AND EXISTS (
|
|
3660
|
+
SELECT 1
|
|
3661
|
+
FROM workspace_sync_outbox_targets AS earlier_target
|
|
3662
|
+
INNER JOIN workspace_sync_outbox_targets AS candidate_target
|
|
3663
|
+
ON candidate_target.tenant_id = earlier_target.tenant_id
|
|
3664
|
+
AND candidate_target.workspace_id = earlier_target.workspace_id
|
|
3665
|
+
AND candidate_target.domain = earlier_target.domain
|
|
3666
|
+
AND candidate_target.entity_id = earlier_target.entity_id
|
|
3667
|
+
WHERE earlier_target.tenant_id = earlier.tenant_id
|
|
3668
|
+
AND earlier_target.workspace_id = earlier.workspace_id
|
|
3669
|
+
AND earlier_target.client_id = earlier.client_id
|
|
3670
|
+
AND earlier_target.operation_id = earlier.operation_id
|
|
3671
|
+
AND candidate_target.client_id = outbox.client_id
|
|
3672
|
+
AND candidate_target.operation_id = outbox.operation_id
|
|
3673
|
+
)
|
|
3674
|
+
AND (
|
|
3675
|
+
earlier.enqueue_sequence < outbox.enqueue_sequence
|
|
3676
|
+
OR (earlier.enqueue_sequence = outbox.enqueue_sequence
|
|
3677
|
+
AND (earlier.created_at < outbox.created_at
|
|
3678
|
+
OR (earlier.created_at = outbox.created_at
|
|
3679
|
+
AND earlier.operation_id < outbox.operation_id)))
|
|
3680
|
+
)
|
|
3681
|
+
)
|
|
3682
|
+
ORDER BY outbox.next_attempt_at ASC, outbox.enqueue_sequence ASC,
|
|
3683
|
+
outbox.created_at ASC, outbox.operation_id ASC
|
|
3684
|
+
LIMIT ?
|
|
3685
|
+
`).all(tenantId, workspaceId, now, now, ...dispatchClasses, limit);
|
|
3686
|
+
const claimed = [];
|
|
3687
|
+
for (const candidate of candidates) {
|
|
3688
|
+
const clientId = requireText(candidate.client_id, 'Client id');
|
|
3689
|
+
const operationId = requireText(candidate.operation_id, 'Operation id');
|
|
3690
|
+
const queued = this.db.prepare(`
|
|
3691
|
+
SELECT domain, entity_id, attempt_count, rebase_before_first_attempt
|
|
3692
|
+
FROM workspace_sync_outbox
|
|
3693
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3694
|
+
`).get(tenantId, workspaceId, clientId, operationId);
|
|
3695
|
+
if (!queued)
|
|
3696
|
+
continue;
|
|
3697
|
+
if (Number(queued.rebase_before_first_attempt) === 1 && Number(queued.attempt_count) === 0) {
|
|
3698
|
+
const queuedDomain = requireText(queued.domain, 'Queued domain');
|
|
3699
|
+
const queuedEntityId = requireText(queued.entity_id, 'Queued entity id');
|
|
3700
|
+
const targets = this.db.prepare(`
|
|
3701
|
+
SELECT domain, entity_id
|
|
3702
|
+
FROM workspace_sync_outbox_targets
|
|
3703
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3704
|
+
ORDER BY domain, entity_id
|
|
3705
|
+
`).all(tenantId, workspaceId, clientId, operationId);
|
|
3706
|
+
let rebasedEntityRevision = '0';
|
|
3707
|
+
let rebasedLifecycleRevision = '0';
|
|
3708
|
+
for (const target of targets) {
|
|
3709
|
+
const targetDomain = requireText(target.domain, 'Queued target domain');
|
|
3710
|
+
const targetEntityId = requireText(target.entity_id, 'Queued target entity id');
|
|
3711
|
+
const revision = this.db.prepare(`
|
|
3712
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
3713
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision
|
|
3714
|
+
FROM workspace_sync_entity_revisions
|
|
3715
|
+
WHERE tenant_id = ? AND workspace_id = ? AND contract_version = ?
|
|
3716
|
+
AND domain = ? AND entity_id = ?
|
|
3717
|
+
`).get(tenantId, workspaceId, WORKSPACE_SYNC_CONTRACT_VERSION, targetDomain, targetEntityId);
|
|
3718
|
+
const targetEntityRevision = normalizeSequence(revision?.entity_revision ?? 0);
|
|
3719
|
+
const targetLifecycleRevision = normalizeSequence(revision?.lifecycle_revision ?? 0);
|
|
3720
|
+
this.db.prepare(`
|
|
3721
|
+
UPDATE workspace_sync_outbox_targets
|
|
3722
|
+
SET base_entity_revision = ?, base_lifecycle_revision = ?
|
|
3723
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3724
|
+
AND domain = ? AND entity_id = ?
|
|
3725
|
+
`).run(targetEntityRevision, targetLifecycleRevision, tenantId, workspaceId, clientId, operationId, targetDomain, targetEntityId);
|
|
3726
|
+
if (targetDomain === queuedDomain && targetEntityId === queuedEntityId) {
|
|
3727
|
+
rebasedEntityRevision = targetEntityRevision;
|
|
3728
|
+
rebasedLifecycleRevision = targetLifecycleRevision;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
this.db.prepare(`
|
|
3732
|
+
UPDATE workspace_sync_outbox
|
|
3733
|
+
SET base_entity_revision = ?, base_lifecycle_revision = ?,
|
|
3734
|
+
rebase_before_first_attempt = 0, updated_at = ?
|
|
3735
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3736
|
+
AND status = 'pending' AND attempt_count = 0
|
|
3737
|
+
`).run(rebasedEntityRevision, rebasedLifecycleRevision, now, tenantId, workspaceId, clientId, operationId);
|
|
3738
|
+
}
|
|
3739
|
+
const updated = this.db.prepare(`
|
|
3740
|
+
UPDATE workspace_sync_outbox
|
|
3741
|
+
SET status = 'in_flight', attempt_count = attempt_count + 1,
|
|
3742
|
+
lease_owner = ?, lease_expires_at = ?, updated_at = ?
|
|
3743
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3744
|
+
AND ((status = 'pending' AND next_attempt_at <= ?)
|
|
3745
|
+
OR (status = 'in_flight' AND lease_expires_at <= ?))
|
|
3746
|
+
`).run(leaseOwner, leaseExpiresAt, now, tenantId, workspaceId, clientId, operationId, now, now);
|
|
3747
|
+
if (Number(updated.changes || 0) === 1)
|
|
3748
|
+
claimed.push({ clientId, operationId });
|
|
3749
|
+
}
|
|
3750
|
+
return claimed.map(({ clientId, operationId }) => {
|
|
3751
|
+
const row = this.db.prepare(`
|
|
3752
|
+
SELECT tenant_id, workspace_id, client_id, operation_id, dispatch_class, domain, entity_id,
|
|
3753
|
+
mutation_kind, owns_task_metadata, owns_task_lifecycle, owns_task_assignee,
|
|
3754
|
+
CAST(base_entity_revision AS TEXT) AS base_entity_revision,
|
|
3755
|
+
CAST(base_lifecycle_revision AS TEXT) AS base_lifecycle_revision,
|
|
3756
|
+
payload_json, payload_fingerprint, status, attempt_count,
|
|
3757
|
+
initial_not_found_attempt_count,
|
|
3758
|
+
CAST(enqueue_sequence AS TEXT) AS enqueue_sequence,
|
|
3759
|
+
rebase_before_first_attempt,
|
|
3760
|
+
workstream_metadata_revision_rebase_count,
|
|
3761
|
+
initiative_metadata_revision_rebase_count,
|
|
3762
|
+
lease_owner, lease_expires_at
|
|
3763
|
+
FROM workspace_sync_outbox
|
|
3764
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3765
|
+
AND status = 'in_flight' AND lease_owner = ?
|
|
3766
|
+
`).get(tenantId, workspaceId, clientId, operationId, leaseOwner);
|
|
3767
|
+
if (!row)
|
|
3768
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
3769
|
+
return this.mapOutboxRow(row);
|
|
3770
|
+
});
|
|
3771
|
+
})();
|
|
3772
|
+
return { leaseOwner, records };
|
|
3773
|
+
}
|
|
3774
|
+
releaseOutboxClaim(input) {
|
|
3775
|
+
const ids = operationParams(input.identity);
|
|
3776
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
3777
|
+
const now = requireIsoTimestamp(input.now, 'Release time');
|
|
3778
|
+
const nextAttemptAt = requireIsoTimestamp(input.nextAttemptAt, 'Next attempt time');
|
|
3779
|
+
const result = this.db.prepare(`
|
|
3780
|
+
UPDATE workspace_sync_outbox
|
|
3781
|
+
SET status = 'pending', next_attempt_at = ?, lease_owner = NULL,
|
|
3782
|
+
lease_expires_at = NULL, last_error_code = ?, last_error_message = ?,
|
|
3783
|
+
initial_not_found_attempt_count = initial_not_found_attempt_count + ?, updated_at = ?
|
|
3784
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3785
|
+
AND status = 'in_flight' AND lease_owner = ? AND lease_expires_at > ?
|
|
3786
|
+
`).run(nextAttemptAt, input.errorCode ?? null, input.errorMessage ?? null, input.incrementInitialNotFoundAttemptCount === true ? 1 : 0, now, ...ids, leaseOwner, now);
|
|
3787
|
+
if (Number(result.changes || 0) !== 1)
|
|
3788
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
3789
|
+
}
|
|
3790
|
+
blockOutboxClaim(input) {
|
|
3791
|
+
const ids = operationParams(input.identity);
|
|
3792
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
3793
|
+
const now = requireIsoTimestamp(input.now, 'Block time');
|
|
3794
|
+
const errorCode = requireText(input.errorCode, 'Error code');
|
|
3795
|
+
const errorMessage = requireText(input.errorMessage, 'Error message');
|
|
3796
|
+
const result = this.db.prepare(`
|
|
3797
|
+
UPDATE workspace_sync_outbox
|
|
3798
|
+
SET status = 'blocked', lease_owner = NULL, lease_expires_at = NULL,
|
|
3799
|
+
last_error_code = ?, last_error_message = ?, updated_at = ?
|
|
3800
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3801
|
+
AND status = 'in_flight' AND lease_owner = ? AND lease_expires_at > ?
|
|
3802
|
+
`).run(errorCode, errorMessage, now, ...ids, leaseOwner, now);
|
|
3803
|
+
if (Number(result.changes || 0) !== 1)
|
|
3804
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
3805
|
+
}
|
|
3806
|
+
applyAuthoritativeAndAck(input) {
|
|
3807
|
+
const ids = operationParams(input.identity);
|
|
3808
|
+
const leaseOwner = requireText(input.leaseOwner, 'Lease owner');
|
|
3809
|
+
const now = requireIsoTimestamp(input.now, 'Acknowledgement time');
|
|
3810
|
+
const responseJson = canonicalWorkspaceSyncJson(input.response);
|
|
3811
|
+
const acceptedSequence = normalizeSequence(input.acceptedSequence);
|
|
3812
|
+
const rawAuthoritative = input.authoritative
|
|
3813
|
+
? (Array.isArray(input.authoritative) ? input.authoritative : [input.authoritative])
|
|
3814
|
+
: [];
|
|
3815
|
+
const authoritative = rawAuthoritative.map((entry) => ({
|
|
3816
|
+
domain: requireText(entry.domain, 'Authoritative domain'),
|
|
3817
|
+
entityId: requireText(entry.entityId, 'Authoritative entity id'),
|
|
3818
|
+
mutationKind: requireText(entry.mutationKind, 'Authoritative mutation kind'),
|
|
3819
|
+
entityRevision: normalizeSequence(entry.entityRevision),
|
|
3820
|
+
lifecycleRevision: normalizeSequence(entry.lifecycleRevision),
|
|
3821
|
+
sequence: normalizeSequence(entry.sequence),
|
|
3822
|
+
canonicalFingerprint: requireText(entry.canonicalFingerprint, 'Authoritative canonical fingerprint'),
|
|
3823
|
+
metadataOwned: entry.metadataOwned === true,
|
|
3824
|
+
reopenAssigneeOwned: entry.reopenAssigneeOwned === true,
|
|
3825
|
+
allowAuthoritativeRevisionReset: entry.allowAuthoritativeRevisionReset === true,
|
|
3826
|
+
}));
|
|
3827
|
+
for (const entry of authoritative) {
|
|
3828
|
+
if (entry.allowAuthoritativeRevisionReset
|
|
3829
|
+
&& (entry.domain !== 'task-comments' || entry.mutationKind !== 'reconcile')) {
|
|
3830
|
+
throw new Error('Authoritative revision reset is reserved for task-comment reconciliation.');
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
const authoritativeKeys = new Set(authoritative.map((entry) => `${entry.domain}\u0000${entry.entityId}`));
|
|
3834
|
+
const authoritativeSequences = new Set(authoritative.map((entry) => entry.sequence));
|
|
3835
|
+
const expectedFirstSequence = BigInt(acceptedSequence) - BigInt(Math.max(0, authoritative.length - 1));
|
|
3836
|
+
const sortedSequences = authoritative
|
|
3837
|
+
.map((entry) => BigInt(entry.sequence))
|
|
3838
|
+
.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
3839
|
+
const hasContiguousSequenceRange = sortedSequences.every((sequence, index) => sequence === expectedFirstSequence + BigInt(index));
|
|
3840
|
+
if (authoritativeKeys.size !== authoritative.length
|
|
3841
|
+
|| authoritativeSequences.size !== authoritative.length
|
|
3842
|
+
|| (authoritative.length > 0 && (!hasContiguousSequenceRange || expectedFirstSequence < 0n))) {
|
|
3843
|
+
throw new Error('Authoritative revisions must be distinct and cover the accepted contiguous sequence range.');
|
|
3844
|
+
}
|
|
3845
|
+
return this.db.transaction(() => {
|
|
3846
|
+
if (authoritative.length > 0) {
|
|
3847
|
+
const lock = this.db.prepare(`
|
|
3848
|
+
INSERT INTO workspace_sync_domain_locks (tenant_id, workspace_id, domain, updated_at)
|
|
3849
|
+
VALUES (?, ?, ?, ?)
|
|
3850
|
+
ON CONFLICT (tenant_id, workspace_id, domain) DO UPDATE SET updated_at = excluded.updated_at
|
|
3851
|
+
`);
|
|
3852
|
+
if (requiresInitiativeProjectionLock(authoritative)) {
|
|
3853
|
+
lock.run(ids[0], ids[1], WORKSPACE_SYNC_INITIATIVE_PROJECTION_LOCK, now);
|
|
3854
|
+
}
|
|
3855
|
+
if (requiresCombinedProjectionLock(authoritative)) {
|
|
3856
|
+
lock.run(ids[0], ids[1], WORKSPACE_SYNC_COMBINED_PROJECTION_LOCK, now);
|
|
3857
|
+
}
|
|
3858
|
+
for (const entry of normalizeWorkspaceSyncLockTargets(authoritative)) {
|
|
3859
|
+
lock.run(ids[0], ids[1], `local-outbox:${entry.domain}:${entry.entityId}`, now);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
const owned = this.db.prepare(`
|
|
3863
|
+
UPDATE workspace_sync_outbox SET updated_at = ?
|
|
3864
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3865
|
+
AND status = 'in_flight' AND lease_owner = ? AND lease_expires_at > ?
|
|
3866
|
+
`).run(now, ...ids, leaseOwner, now);
|
|
3867
|
+
if (Number(owned.changes || 0) !== 1)
|
|
3868
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
3869
|
+
if (authoritative.length > 0) {
|
|
3870
|
+
const outbox = this.db.prepare(`
|
|
3871
|
+
SELECT domain, entity_id, mutation_kind FROM workspace_sync_outbox
|
|
3872
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3873
|
+
AND status = 'in_flight' AND lease_owner = ?
|
|
3874
|
+
`).get(...ids, leaseOwner);
|
|
3875
|
+
const root = authoritative.find((entry) => (entry.domain === outbox?.domain
|
|
3876
|
+
&& entry.entityId === outbox?.entity_id
|
|
3877
|
+
&& entry.mutationKind === outbox?.mutation_kind));
|
|
3878
|
+
if (!root) {
|
|
3879
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
3880
|
+
}
|
|
3881
|
+
const readCurrentRevision = this.db.prepare(`
|
|
3882
|
+
SELECT CAST(entity_revision AS TEXT) AS entity_revision,
|
|
3883
|
+
CAST(lifecycle_revision AS TEXT) AS lifecycle_revision,
|
|
3884
|
+
CAST(last_sequence AS TEXT) AS last_sequence
|
|
3885
|
+
FROM workspace_sync_entity_revisions
|
|
3886
|
+
WHERE tenant_id = ? AND workspace_id = ? AND domain = ? AND entity_id = ?
|
|
3887
|
+
`);
|
|
3888
|
+
for (const entry of authoritative) {
|
|
3889
|
+
const currentRevision = readCurrentRevision.get(ids[0], ids[1], entry.domain, entry.entityId);
|
|
3890
|
+
if ((!entry.allowAuthoritativeRevisionReset && (BigInt(entry.entityRevision) < BigInt(normalizeSequence(currentRevision?.entity_revision ?? 0))
|
|
3891
|
+
|| BigInt(entry.lifecycleRevision)
|
|
3892
|
+
< BigInt(normalizeSequence(currentRevision?.lifecycle_revision ?? 0))))
|
|
3893
|
+
|| BigInt(entry.sequence) < BigInt(normalizeSequence(currentRevision?.last_sequence ?? 0))) {
|
|
3894
|
+
throw new WorkspaceSyncRevisionConflictError();
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
const result = input.applyAuthoritative();
|
|
3899
|
+
if (authoritative.length > 0) {
|
|
3900
|
+
const upsertRevision = this.db.prepare(`
|
|
3901
|
+
INSERT INTO workspace_sync_entity_revisions (
|
|
3902
|
+
tenant_id, workspace_id, domain, entity_id, contract_version,
|
|
3903
|
+
entity_revision, lifecycle_revision, coexistence_owned, metadata_owned, reopen_assignee_owned, last_sequence,
|
|
3904
|
+
canonical_fingerprint, updated_at
|
|
3905
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
|
3906
|
+
ON CONFLICT (tenant_id, workspace_id, domain, entity_id) DO UPDATE SET
|
|
3907
|
+
contract_version = excluded.contract_version,
|
|
3908
|
+
entity_revision = excluded.entity_revision,
|
|
3909
|
+
lifecycle_revision = excluded.lifecycle_revision,
|
|
3910
|
+
coexistence_owned = 1,
|
|
3911
|
+
metadata_owned = CASE
|
|
3912
|
+
WHEN excluded.metadata_owned = 1 THEN 1
|
|
3913
|
+
ELSE workspace_sync_entity_revisions.metadata_owned
|
|
3914
|
+
END,
|
|
3915
|
+
reopen_assignee_owned = CASE
|
|
3916
|
+
WHEN excluded.reopen_assignee_owned = 1 THEN 1
|
|
3917
|
+
ELSE workspace_sync_entity_revisions.reopen_assignee_owned
|
|
3918
|
+
END,
|
|
3919
|
+
last_sequence = excluded.last_sequence,
|
|
3920
|
+
canonical_fingerprint = excluded.canonical_fingerprint,
|
|
3921
|
+
updated_at = excluded.updated_at
|
|
3922
|
+
`);
|
|
3923
|
+
for (const entry of authoritative) {
|
|
3924
|
+
upsertRevision.run(ids[0], ids[1], entry.domain, entry.entityId, WORKSPACE_SYNC_CONTRACT_VERSION, entry.entityRevision, entry.lifecycleRevision, entry.domain === 'task' && (entry.mutationKind === 'metadata-update'
|
|
3925
|
+
|| entry.mutationKind === 'create'
|
|
3926
|
+
|| entry.metadataOwned === true) ? 1 : 0, entry.domain === 'task' && entry.reopenAssigneeOwned === true ? 1 : 0, entry.sequence, entry.canonicalFingerprint, now);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
const acked = this.db.prepare(`
|
|
3930
|
+
UPDATE workspace_sync_outbox
|
|
3931
|
+
SET status = 'accepted', authoritative_response_json = ?, accepted_sequence = ?,
|
|
3932
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
3933
|
+
last_error_code = NULL, last_error_message = NULL, updated_at = ?
|
|
3934
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
3935
|
+
AND status = 'in_flight' AND lease_owner = ? AND lease_expires_at > ?
|
|
3936
|
+
`).run(responseJson, acceptedSequence, now, ...ids, leaseOwner, now);
|
|
3937
|
+
if (Number(acked.changes || 0) !== 1)
|
|
3938
|
+
throw new WorkspaceSyncLeaseLostError();
|
|
3939
|
+
return result;
|
|
3940
|
+
})();
|
|
3941
|
+
}
|
|
3942
|
+
isTargetStateLifecycleConflict(source) {
|
|
3943
|
+
const mutationKind = String(source.mutation_kind || '');
|
|
3944
|
+
const errorCode = String(source.last_error_code || '');
|
|
3945
|
+
if (mutationKind === 'archive' && errorCode === 'TASK_LIFECYCLE_ALREADY_ARCHIVED')
|
|
3946
|
+
return true;
|
|
3947
|
+
if (mutationKind === 'unarchive' && errorCode === 'TASK_LIFECYCLE_NOT_ARCHIVED')
|
|
3948
|
+
return true;
|
|
3949
|
+
if (errorCode !== 'TASK_LIFECYCLE_STATE_CONFLICT')
|
|
3950
|
+
return false;
|
|
3951
|
+
const legacyMessage = String(source.last_error_message || '');
|
|
3952
|
+
// Persisted pre-0.3.327 rows used one overloaded code. Exact legacy
|
|
3953
|
+
// messages are recognized only to drain already-blocked recoveries;
|
|
3954
|
+
// new writes use the typed codes above.
|
|
3955
|
+
return (mutationKind === 'archive' && legacyMessage === 'Task is already archived.')
|
|
3956
|
+
|| (mutationKind === 'unarchive' && legacyMessage === 'Task is not archived.');
|
|
3957
|
+
}
|
|
3958
|
+
isTaskLifecycleRecoveryIntentSatisfied(tenantId, workspaceId, source) {
|
|
3959
|
+
const mutationKind = String(source.mutation_kind || '');
|
|
3960
|
+
if (mutationKind !== 'archive' && mutationKind !== 'unarchive')
|
|
3961
|
+
return false;
|
|
3962
|
+
let payload;
|
|
3963
|
+
try {
|
|
3964
|
+
payload = JSON.parse(String(source.payload_json || ''));
|
|
3965
|
+
}
|
|
3966
|
+
catch {
|
|
3967
|
+
return false;
|
|
3968
|
+
}
|
|
3969
|
+
if (mutationKind === 'archive'
|
|
3970
|
+
&& (payload.cancellation !== false || payload.reason !== null)) {
|
|
3971
|
+
return false;
|
|
3972
|
+
}
|
|
3973
|
+
const task = this.db.prepare(`
|
|
3974
|
+
SELECT status, is_archived, completed_at, canceled_reason
|
|
3975
|
+
FROM tasks
|
|
3976
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
3977
|
+
`).get(tenantId, workspaceId, requireText(source.entity_id, 'Recovery task id'));
|
|
3978
|
+
if (!task)
|
|
3979
|
+
return false;
|
|
3980
|
+
const isArchived = task.is_archived === true || Number(task.is_archived || 0) === 1;
|
|
3981
|
+
const status = String(task.status || '').trim().toLowerCase();
|
|
3982
|
+
const completedAt = typeof task.completed_at === 'string' && task.completed_at.trim()
|
|
3983
|
+
? task.completed_at.trim()
|
|
3984
|
+
: null;
|
|
3985
|
+
const canceledReason = typeof task.canceled_reason === 'string' && task.canceled_reason.trim()
|
|
3986
|
+
? task.canceled_reason.trim()
|
|
3987
|
+
: null;
|
|
3988
|
+
if (mutationKind === 'archive') {
|
|
3989
|
+
return isArchived && status === 'done' && completedAt !== null && canceledReason === null;
|
|
3990
|
+
}
|
|
3991
|
+
return !isArchived && status === 'task' && completedAt === null && canceledReason === null;
|
|
3992
|
+
}
|
|
3993
|
+
normalizeRecoveredWorkflowStepEvidence(tenantId, workspaceId, assignment) {
|
|
3994
|
+
const version = assignment.version;
|
|
3995
|
+
const requestedSteps = Array.isArray(assignment.steps) ? assignment.steps : null;
|
|
3996
|
+
const workflowVersionId = String(version?.id || '');
|
|
3997
|
+
if (!workflowVersionId || !requestedSteps)
|
|
3998
|
+
return null;
|
|
3999
|
+
const workflowStepsTable = this.db.provider === 'postgres'
|
|
4000
|
+
? this.db.prepare(`
|
|
4001
|
+
SELECT 1 AS present
|
|
4002
|
+
FROM information_schema.tables
|
|
4003
|
+
WHERE table_schema = current_schema() AND table_name = 'workflow_steps'
|
|
4004
|
+
`).get()
|
|
4005
|
+
: this.db.prepare(`
|
|
4006
|
+
SELECT 1 AS present
|
|
4007
|
+
FROM sqlite_master
|
|
4008
|
+
WHERE type = 'table' AND name = 'workflow_steps'
|
|
4009
|
+
`).get();
|
|
4010
|
+
if (!workflowStepsTable)
|
|
4011
|
+
return null;
|
|
4012
|
+
const rows = this.db.prepare(`
|
|
4013
|
+
SELECT id, tenant_id, workspace_id, workflow_version_id, position,
|
|
4014
|
+
name, instructions, owner_type, owner_id, expected_artifact,
|
|
4015
|
+
requires_comment_evidence, is_required, created_at, updated_at
|
|
4016
|
+
FROM workflow_steps
|
|
4017
|
+
WHERE tenant_id = ? AND workspace_id = ? AND workflow_version_id = ?
|
|
4018
|
+
ORDER BY position, id
|
|
4019
|
+
`).all(tenantId, workspaceId, workflowVersionId);
|
|
4020
|
+
if (rows.length !== requestedSteps.length)
|
|
4021
|
+
return null;
|
|
4022
|
+
const canonicalById = new Map(rows.map((row) => {
|
|
4023
|
+
const step = {
|
|
4024
|
+
id: requireText(row.id, 'Workflow step id'),
|
|
4025
|
+
tenantId: requireText(row.tenant_id, 'Workflow step tenant id'),
|
|
4026
|
+
workspaceId: requireText(row.workspace_id, 'Workflow step workspace id'),
|
|
4027
|
+
workflowVersionId: requireText(row.workflow_version_id, 'Workflow step version id'),
|
|
4028
|
+
position: Number(row.position),
|
|
4029
|
+
name: requireText(row.name, 'Workflow step name'),
|
|
4030
|
+
instructions: String(row.instructions || ''),
|
|
4031
|
+
ownerType: row.owner_type === 'user' || row.owner_type === 'agent'
|
|
4032
|
+
? row.owner_type
|
|
4033
|
+
: null,
|
|
4034
|
+
ownerId: row.owner_id == null ? null : String(row.owner_id),
|
|
4035
|
+
expectedArtifact: row.expected_artifact == null ? null : String(row.expected_artifact),
|
|
4036
|
+
requiresCommentEvidence: Number(row.requires_comment_evidence) === 1,
|
|
4037
|
+
isRequired: Number(row.is_required) !== 0,
|
|
4038
|
+
createdAt: requireText(row.created_at, 'Workflow step created time'),
|
|
4039
|
+
updatedAt: requireText(row.updated_at, 'Workflow step updated time'),
|
|
4040
|
+
};
|
|
4041
|
+
return [step.id, step];
|
|
4042
|
+
}));
|
|
4043
|
+
let changed = false;
|
|
4044
|
+
const normalizedSteps = requestedSteps.map((rawStep) => {
|
|
4045
|
+
if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep))
|
|
4046
|
+
return null;
|
|
4047
|
+
const requested = rawStep;
|
|
4048
|
+
const canonical = canonicalById.get(String(requested.id || ''));
|
|
4049
|
+
if (!canonical)
|
|
4050
|
+
return null;
|
|
4051
|
+
const { requiresCommentEvidence: _requestedEvidence, ...requestedDefinition } = requested;
|
|
4052
|
+
const { requiresCommentEvidence: canonicalEvidence, ...canonicalDefinition } = canonical;
|
|
4053
|
+
if (canonicalWorkspaceSyncJson(requestedDefinition)
|
|
4054
|
+
!== canonicalWorkspaceSyncJson(canonicalDefinition)) {
|
|
4055
|
+
return null;
|
|
4056
|
+
}
|
|
4057
|
+
if (requested.requiresCommentEvidence !== canonicalEvidence) {
|
|
4058
|
+
if (requested.requiresCommentEvidence !== true || canonicalEvidence !== false) {
|
|
4059
|
+
return null;
|
|
4060
|
+
}
|
|
4061
|
+
changed = true;
|
|
4062
|
+
}
|
|
4063
|
+
return {
|
|
4064
|
+
...requested,
|
|
4065
|
+
requiresCommentEvidence: canonicalEvidence,
|
|
4066
|
+
};
|
|
4067
|
+
});
|
|
4068
|
+
if (normalizedSteps.some((step) => step === null))
|
|
4069
|
+
return null;
|
|
4070
|
+
return {
|
|
4071
|
+
assignment: {
|
|
4072
|
+
...assignment,
|
|
4073
|
+
steps: normalizedSteps,
|
|
4074
|
+
},
|
|
4075
|
+
changed,
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
prepareRecoveryPayload(tenantId, workspaceId, source) {
|
|
4079
|
+
if (source.domain === 'task-comments' && source.mutation_kind === 'append') {
|
|
4080
|
+
return this.prepareTaskCommentAppendRecoveryPayload(tenantId, workspaceId, source);
|
|
4081
|
+
}
|
|
4082
|
+
if (source.dispatch_class === 'legacy-adoption'
|
|
4083
|
+
&& source.domain === 'entity-event'
|
|
4084
|
+
&& source.mutation_kind === 'adopt-legacy-batch') {
|
|
4085
|
+
const payload = this.readEntityEventLegacyAdoptionRecoveryIntent(workspaceId, source);
|
|
4086
|
+
const payloadJson = canonicalWorkspaceSyncJson(payload);
|
|
4087
|
+
return {
|
|
4088
|
+
payloadJson,
|
|
4089
|
+
payloadFingerprint: fingerprintWorkspaceSyncPayload(payload),
|
|
4090
|
+
replayMutationKind: 'adopt-legacy-batch',
|
|
4091
|
+
workflowDependencyCoalesced: false,
|
|
4092
|
+
};
|
|
4093
|
+
}
|
|
4094
|
+
return {
|
|
4095
|
+
...this.prepareTaskRecoveryPayload(tenantId, workspaceId, source),
|
|
4096
|
+
replayMutationKind: requireText(source.mutation_kind, 'Source mutation kind'),
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
parseFrozenRecoveryPayload(source) {
|
|
4100
|
+
let payload;
|
|
4101
|
+
try {
|
|
4102
|
+
const parsed = JSON.parse(String(source.payload_json || ''));
|
|
4103
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
4104
|
+
throw new Error('invalid');
|
|
4105
|
+
}
|
|
4106
|
+
payload = parsed;
|
|
4107
|
+
}
|
|
4108
|
+
catch {
|
|
4109
|
+
throw new Error('Workspace sync recovery payload is not valid JSON.');
|
|
4110
|
+
}
|
|
4111
|
+
if (fingerprintWorkspaceSyncPayload(payload) !== source.payload_fingerprint) {
|
|
4112
|
+
throw new Error('Workspace sync recovery payload fingerprint does not match.');
|
|
4113
|
+
}
|
|
4114
|
+
return payload;
|
|
4115
|
+
}
|
|
4116
|
+
readEntityEventLegacyAdoptionRecoveryIntent(workspaceId, source) {
|
|
4117
|
+
const payload = this.parseFrozenRecoveryPayload(source);
|
|
4118
|
+
if (Object.keys(payload).length !== 1 || !Array.isArray(payload.events)
|
|
4119
|
+
|| payload.events.length < 1 || payload.events.length > 100) {
|
|
4120
|
+
throw new Error('Legacy entity-event adoption recovery payload is invalid.');
|
|
4121
|
+
}
|
|
4122
|
+
if (Buffer.byteLength(canonicalWorkspaceSyncJson(payload), 'utf8')
|
|
4123
|
+
> LEGACY_ENTITY_EVENT_ADOPTION_MAX_PAYLOAD_BYTES) {
|
|
4124
|
+
throw new Error('Legacy entity-event adoption recovery payload exceeds the transport-safe byte limit.');
|
|
4125
|
+
}
|
|
4126
|
+
const events = payload.events.map((value) => validateWorkspaceSyncV3EntityEventPayload(value));
|
|
4127
|
+
if (events.some((event) => event.workspaceId !== workspaceId)) {
|
|
4128
|
+
throw new Error('Legacy entity-event adoption recovery event identity is invalid.');
|
|
4129
|
+
}
|
|
4130
|
+
if (events[0].id !== source.entity_id
|
|
4131
|
+
|| new Set(events.map((event) => event.id)).size !== events.length) {
|
|
4132
|
+
throw new Error('Legacy entity-event adoption recovery batch identity is invalid.');
|
|
4133
|
+
}
|
|
4134
|
+
return { events };
|
|
4135
|
+
}
|
|
4136
|
+
readTaskCreateRecoveryIntent(source) {
|
|
4137
|
+
const payload = this.parseFrozenRecoveryPayload(source);
|
|
4138
|
+
const allowedPayloadKeys = new Set(['task', 'localCarriers', 'originActor']);
|
|
4139
|
+
if (Object.keys(payload).some((key) => !allowedPayloadKeys.has(key))) {
|
|
4140
|
+
throw new Error('Task create recovery payload contains unsupported fields.');
|
|
4141
|
+
}
|
|
4142
|
+
if (payload.originActor !== undefined)
|
|
4143
|
+
parseWorkspaceSyncV3OriginActor(payload.originActor);
|
|
4144
|
+
if (!payload.task || typeof payload.task !== 'object' || Array.isArray(payload.task)) {
|
|
4145
|
+
throw new Error('Task create recovery payload is invalid.');
|
|
4146
|
+
}
|
|
4147
|
+
const task = payload.task;
|
|
4148
|
+
if (Object.keys(task).some((key) => !TASK_CREATE_FIELDS.has(key))) {
|
|
4149
|
+
throw new Error('Task create recovery payload contains unsupported task fields.');
|
|
4150
|
+
}
|
|
4151
|
+
if (typeof task.title !== 'string' || !task.title.trim() || task.title !== task.title.trim()) {
|
|
4152
|
+
throw new Error('Task create recovery payload title is not canonical.');
|
|
4153
|
+
}
|
|
4154
|
+
const carriers = payload.localCarriers;
|
|
4155
|
+
if (!carriers || typeof carriers !== 'object' || Array.isArray(carriers)
|
|
4156
|
+
|| canonicalWorkspaceSyncJson(Object.keys(carriers).sort())
|
|
4157
|
+
!== canonicalWorkspaceSyncJson(['attachments', 'checklistItems', 'comments'])
|
|
4158
|
+
|| !Array.isArray(carriers.attachments)
|
|
4159
|
+
|| !Array.isArray(carriers.checklistItems)
|
|
4160
|
+
|| !Array.isArray(carriers.comments)
|
|
4161
|
+
|| carriers.attachments.length > 0
|
|
4162
|
+
|| carriers.checklistItems.length > 0
|
|
4163
|
+
|| carriers.comments.length > 0) {
|
|
4164
|
+
throw new Error('Task create recovery requires empty local carriers; nested content needs explicit recovery.');
|
|
4165
|
+
}
|
|
4166
|
+
return { task };
|
|
4167
|
+
}
|
|
4168
|
+
assertTaskCreateRecoveryIntentSatisfied(tenantId, workspaceId, source) {
|
|
4169
|
+
if (!TASK_CREATE_RECOVERY_ERROR_CODES.has(String(source.last_error_code || ''))) {
|
|
4170
|
+
throw new Error('Task create recovery requires an authoritative identity or revision conflict.');
|
|
4171
|
+
}
|
|
4172
|
+
const { task } = this.readTaskCreateRecoveryIntent(source);
|
|
4173
|
+
const installed = this.db.prepare(`
|
|
4174
|
+
SELECT title, description, priority, complexity, type, category, assignee, status,
|
|
4175
|
+
scheduled_date, due_date, order_in_day, workstream_id, taxonomies,
|
|
4176
|
+
reference_number, local_reference_number, is_archived
|
|
4177
|
+
FROM tasks
|
|
4178
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
4179
|
+
`).get(tenantId, workspaceId, requireText(source.entity_id, 'Source entity id'));
|
|
4180
|
+
if (!installed || Number(installed.is_archived || 0) === 1
|
|
4181
|
+
|| !Number.isSafeInteger(Number(installed.reference_number))
|
|
4182
|
+
|| Number(installed.reference_number) < 1
|
|
4183
|
+
|| installed.local_reference_number !== null) {
|
|
4184
|
+
throw new Error('Task create recovery requires a non-tombstoned authoritative task baseline.');
|
|
4185
|
+
}
|
|
4186
|
+
const installedByField = {
|
|
4187
|
+
title: installed.title,
|
|
4188
|
+
description: installed.description,
|
|
4189
|
+
priority: installed.priority == null ? null : Number(installed.priority),
|
|
4190
|
+
complexity: installed.complexity == null ? null : Number(installed.complexity),
|
|
4191
|
+
type: installed.type,
|
|
4192
|
+
category: installed.category,
|
|
4193
|
+
assignee: installed.assignee,
|
|
4194
|
+
status: installed.status,
|
|
4195
|
+
scheduledDate: installed.scheduled_date ?? null,
|
|
4196
|
+
dueDate: installed.due_date ?? null,
|
|
4197
|
+
orderInDay: installed.order_in_day == null ? null : Number(installed.order_in_day),
|
|
4198
|
+
workstreamId: installed.workstream_id ?? null,
|
|
4199
|
+
taxonomies: (() => {
|
|
4200
|
+
try {
|
|
4201
|
+
return JSON.parse(String(installed.taxonomies || '{}'));
|
|
4202
|
+
}
|
|
4203
|
+
catch {
|
|
4204
|
+
throw new Error('Authoritative task taxonomy baseline is invalid.');
|
|
4205
|
+
}
|
|
4206
|
+
})(),
|
|
4207
|
+
};
|
|
4208
|
+
for (const [field, value] of Object.entries(task)) {
|
|
4209
|
+
if (canonicalWorkspaceSyncJson(installedByField[field])
|
|
4210
|
+
!== canonicalWorkspaceSyncJson(value)) {
|
|
4211
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
const ownership = this.readWorkspaceDomainOwnership({
|
|
4215
|
+
tenantId,
|
|
4216
|
+
workspaceId,
|
|
4217
|
+
domain: 'task',
|
|
4218
|
+
entityId: requireText(source.entity_id, 'Source entity id'),
|
|
4219
|
+
});
|
|
4220
|
+
if (!ownership.owned || ownership.activeOperation) {
|
|
4221
|
+
throw new Error('Task create recovery requires its authoritative task baseline to settle first.');
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
readTaskAttachmentLinksRecoveryIntent(source) {
|
|
4225
|
+
const payload = this.parseFrozenRecoveryPayload(source);
|
|
4226
|
+
const allowedPayloadKeys = new Set(['links', 'originActor']);
|
|
4227
|
+
if (Object.keys(payload).some((key) => !allowedPayloadKeys.has(key))
|
|
4228
|
+
|| !Array.isArray(payload.links)) {
|
|
4229
|
+
throw new Error('Task attachment-link recovery payload is invalid.');
|
|
4230
|
+
}
|
|
4231
|
+
if (payload.originActor !== undefined)
|
|
4232
|
+
parseWorkspaceSyncV3OriginActor(payload.originActor);
|
|
4233
|
+
const identities = new Set();
|
|
4234
|
+
const links = payload.links.map((raw, index) => {
|
|
4235
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
4236
|
+
throw new Error('Task attachment-link recovery payload is invalid.');
|
|
4237
|
+
}
|
|
4238
|
+
const link = raw;
|
|
4239
|
+
if (Object.keys(link).some((key) => !TASK_ATTACHMENT_LINK_FIELDS.has(key))) {
|
|
4240
|
+
throw new Error('Task attachment-link recovery payload contains unsupported fields.');
|
|
4241
|
+
}
|
|
4242
|
+
const assetId = requireText(link.assetId, 'Task attachment-link asset id');
|
|
4243
|
+
if (link.assetId !== assetId) {
|
|
4244
|
+
throw new Error('Task attachment-link recovery payload is not canonical.');
|
|
4245
|
+
}
|
|
4246
|
+
const role = String(link.role || '');
|
|
4247
|
+
if (!['attachment', 'image', 'reference'].includes(role)
|
|
4248
|
+
|| !Number.isSafeInteger(link.order)
|
|
4249
|
+
|| Number(link.order) !== index) {
|
|
4250
|
+
throw new Error('Task attachment-link recovery payload is not canonical.');
|
|
4251
|
+
}
|
|
4252
|
+
const identity = `${assetId}\0${role}`;
|
|
4253
|
+
if (identities.has(identity)) {
|
|
4254
|
+
throw new Error('Task attachment-link recovery payload contains a duplicate identity.');
|
|
4255
|
+
}
|
|
4256
|
+
identities.add(identity);
|
|
4257
|
+
for (const field of ['caption', 'displayName', 'originalFilename']) {
|
|
4258
|
+
if (link[field] !== undefined && typeof link[field] !== 'string') {
|
|
4259
|
+
throw new Error('Task attachment-link recovery metadata is invalid.');
|
|
4260
|
+
}
|
|
4261
|
+
if (typeof link[field] === 'string'
|
|
4262
|
+
&& TASK_ATTACHMENT_FORBIDDEN_METADATA_VALUE.test(link[field])) {
|
|
4263
|
+
throw new Error('Task attachment-link recovery metadata contains a forbidden local, inline, or remote URL value.');
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
if (link.referenceNumber !== undefined && link.referenceNumber !== null
|
|
4267
|
+
&& (!Number.isSafeInteger(link.referenceNumber) || Number(link.referenceNumber) < 1)) {
|
|
4268
|
+
throw new Error('Task attachment-link recovery reference number is invalid.');
|
|
4269
|
+
}
|
|
4270
|
+
if (link.referenceLabel !== undefined && link.referenceLabel !== null
|
|
4271
|
+
&& typeof link.referenceLabel !== 'string') {
|
|
4272
|
+
throw new Error('Task attachment-link recovery reference label is invalid.');
|
|
4273
|
+
}
|
|
4274
|
+
return {
|
|
4275
|
+
assetId,
|
|
4276
|
+
role,
|
|
4277
|
+
order: Number(link.order),
|
|
4278
|
+
displayName: typeof link.displayName === 'string' ? link.displayName : null,
|
|
4279
|
+
};
|
|
4280
|
+
});
|
|
4281
|
+
return { links };
|
|
4282
|
+
}
|
|
4283
|
+
isTaskAttachmentLinksRecoveryIntentSatisfied(tenantId, workspaceId, source) {
|
|
4284
|
+
const intent = this.readTaskAttachmentLinksRecoveryIntent(source);
|
|
4285
|
+
const installed = this.db.prepare(`
|
|
4286
|
+
SELECT asset_id, role, sort_order, display_name
|
|
4287
|
+
FROM workspace_asset_links
|
|
4288
|
+
WHERE tenant_id = ? AND workspace_id = ? AND task_id = ?
|
|
4289
|
+
AND target_type = 'task' AND deleted_at IS NULL
|
|
4290
|
+
ORDER BY sort_order, asset_id, role
|
|
4291
|
+
`).all(tenantId, workspaceId, requireText(source.entity_id, 'Source entity id')).map((link, index) => ({
|
|
4292
|
+
assetId: String(link.asset_id || ''),
|
|
4293
|
+
role: String(link.role || ''),
|
|
4294
|
+
order: Number.isSafeInteger(link.sort_order) && Number(link.sort_order) >= 0
|
|
4295
|
+
? Number(link.sort_order)
|
|
4296
|
+
: index,
|
|
4297
|
+
displayName: typeof link.display_name === 'string' ? link.display_name : null,
|
|
4298
|
+
}));
|
|
4299
|
+
return canonicalWorkspaceSyncJson(installed) === canonicalWorkspaceSyncJson(intent.links);
|
|
4300
|
+
}
|
|
4301
|
+
readTaskCommentAppendRecoveryIntent(workspaceId, source) {
|
|
4302
|
+
let payload;
|
|
4303
|
+
try {
|
|
4304
|
+
payload = JSON.parse(String(source.payload_json || ''));
|
|
4305
|
+
}
|
|
4306
|
+
catch {
|
|
4307
|
+
throw new Error('Workspace sync recovery payload is not valid JSON.');
|
|
4308
|
+
}
|
|
4309
|
+
if (fingerprintWorkspaceSyncPayload(payload) !== source.payload_fingerprint) {
|
|
4310
|
+
throw new Error('Workspace sync recovery payload fingerprint does not match.');
|
|
4311
|
+
}
|
|
4312
|
+
const keys = Object.keys(payload).sort();
|
|
4313
|
+
const hasOriginActor = payload.originActor !== undefined;
|
|
4314
|
+
const expectedKeys = hasOriginActor ? ['comment', 'originActor'] : ['comment'];
|
|
4315
|
+
if (canonicalWorkspaceSyncJson(keys) !== canonicalWorkspaceSyncJson(expectedKeys)) {
|
|
4316
|
+
throw new Error('Task comment recovery payload contains unsupported fields.');
|
|
4317
|
+
}
|
|
4318
|
+
if (hasOriginActor)
|
|
4319
|
+
parseWorkspaceSyncV3OriginActor(payload.originActor);
|
|
4320
|
+
if (!payload.comment || typeof payload.comment !== 'object' || Array.isArray(payload.comment)) {
|
|
4321
|
+
throw new Error('Task comment recovery payload is invalid.');
|
|
4322
|
+
}
|
|
4323
|
+
const commentKeys = Object.keys(payload.comment).sort();
|
|
4324
|
+
if (canonicalWorkspaceSyncJson(commentKeys)
|
|
4325
|
+
!== canonicalWorkspaceSyncJson(['author', 'id', 'submittedAuthor', 'text', 'timestamp'])) {
|
|
4326
|
+
throw new Error('Task comment recovery payload is not canonical.');
|
|
4327
|
+
}
|
|
4328
|
+
const comments = normalizePlanningComments([payload.comment]);
|
|
4329
|
+
if (!comments || comments.length !== 1
|
|
4330
|
+
|| canonicalWorkspaceSyncJson(comments[0]) !== canonicalWorkspaceSyncJson(payload.comment)) {
|
|
4331
|
+
throw new Error('Task comment recovery payload is not canonical.');
|
|
4332
|
+
}
|
|
4333
|
+
return {
|
|
4334
|
+
comment: comments[0],
|
|
4335
|
+
activityEventId: buildTaskCommentActivityEventId({
|
|
4336
|
+
workspaceId: requireText(workspaceId, 'Source workspace id'),
|
|
4337
|
+
clientId: requireText(source.client_id, 'Source client id'),
|
|
4338
|
+
operationId: requireText(source.operation_id, 'Source operation id'),
|
|
4339
|
+
taskId: requireText(source.entity_id, 'Source entity id'),
|
|
4340
|
+
commentId: comments[0].id,
|
|
4341
|
+
}),
|
|
4342
|
+
};
|
|
4343
|
+
}
|
|
4344
|
+
prepareTaskCommentAppendRecoveryPayload(tenantId, workspaceId, source) {
|
|
4345
|
+
const intent = this.readTaskCommentAppendRecoveryIntent(workspaceId, source);
|
|
4346
|
+
this.assertTaskCommentAppendRecoveryEventOwned(tenantId, workspaceId, source, intent);
|
|
4347
|
+
const replayPayload = {
|
|
4348
|
+
comments: [intent.comment],
|
|
4349
|
+
provenance: [{
|
|
4350
|
+
commentId: intent.comment.id,
|
|
4351
|
+
actor: {
|
|
4352
|
+
kind: 'authoritative-entity-event',
|
|
4353
|
+
eventId: intent.activityEventId,
|
|
4354
|
+
},
|
|
4355
|
+
}],
|
|
4356
|
+
};
|
|
4357
|
+
const payloadJson = canonicalWorkspaceSyncJson(replayPayload);
|
|
4358
|
+
return {
|
|
4359
|
+
payloadJson,
|
|
4360
|
+
payloadFingerprint: fingerprintWorkspaceSyncPayload(replayPayload),
|
|
4361
|
+
replayMutationKind: 'reconcile',
|
|
4362
|
+
workflowDependencyCoalesced: false,
|
|
4363
|
+
};
|
|
4364
|
+
}
|
|
4365
|
+
assertTaskCommentAppendRecoveryEventOwned(tenantId, workspaceId, source, intent = this.readTaskCommentAppendRecoveryIntent(workspaceId, source)) {
|
|
4366
|
+
const taskId = requireText(source.entity_id, 'Source entity id');
|
|
4367
|
+
const event = this.db.prepare(`
|
|
4368
|
+
SELECT entity_type, entity_id, action, actor, details_json
|
|
4369
|
+
FROM entity_events
|
|
4370
|
+
WHERE id = ? AND tenant_id = ? AND workspace_id = ?
|
|
4371
|
+
`).get(intent.activityEventId, tenantId, workspaceId);
|
|
4372
|
+
let details = null;
|
|
4373
|
+
try {
|
|
4374
|
+
const parsed = JSON.parse(String(event?.details_json || ''));
|
|
4375
|
+
details = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
4376
|
+
? parsed
|
|
4377
|
+
: null;
|
|
4378
|
+
}
|
|
4379
|
+
catch {
|
|
4380
|
+
details = null;
|
|
4381
|
+
}
|
|
4382
|
+
const expectedDetails = {
|
|
4383
|
+
commentId: intent.comment.id,
|
|
4384
|
+
preview: intent.comment.text.slice(0, 160),
|
|
4385
|
+
submittedAuthor: String(intent.comment.submittedAuthor || intent.comment.author || '').trim(),
|
|
4386
|
+
};
|
|
4387
|
+
const ownership = this.readWorkspaceDomainOwnership({
|
|
4388
|
+
tenantId,
|
|
4389
|
+
workspaceId,
|
|
4390
|
+
domain: 'entity-event',
|
|
4391
|
+
entityId: intent.activityEventId,
|
|
4392
|
+
});
|
|
4393
|
+
if (!event
|
|
4394
|
+
|| event.entity_type !== 'task'
|
|
4395
|
+
|| event.entity_id !== taskId
|
|
4396
|
+
|| event.action !== 'task-comment-added'
|
|
4397
|
+
|| event.actor !== intent.comment.author
|
|
4398
|
+
|| canonicalWorkspaceSyncJson(details) !== canonicalWorkspaceSyncJson(expectedDetails)
|
|
4399
|
+
|| !ownership.owned
|
|
4400
|
+
|| ownership.activeOperation) {
|
|
4401
|
+
throw new Error('Task comment recovery requires its authoritative entity-event adoption to settle first.');
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
isTaskCommentAppendRecoveryIntentSatisfied(tenantId, workspaceId, source) {
|
|
4405
|
+
const intent = this.readTaskCommentAppendRecoveryIntent(workspaceId, source);
|
|
4406
|
+
const installed = this.db.prepare(`
|
|
4407
|
+
SELECT id, text, author, timestamp, submitted_author
|
|
4408
|
+
FROM comments
|
|
4409
|
+
WHERE tenant_id = ? AND workspace_id = ? AND task_id = ? AND id = ?
|
|
4410
|
+
`).get(tenantId, workspaceId, requireText(source.entity_id, 'Source entity id'), intent.comment.id);
|
|
4411
|
+
if (!installed)
|
|
4412
|
+
return false;
|
|
4413
|
+
const installedComments = normalizePlanningComments([{
|
|
4414
|
+
id: installed.id,
|
|
4415
|
+
text: installed.text,
|
|
4416
|
+
author: installed.author,
|
|
4417
|
+
timestamp: installed.timestamp,
|
|
4418
|
+
...(installed.submitted_author == null
|
|
4419
|
+
? {}
|
|
4420
|
+
: { submittedAuthor: installed.submitted_author }),
|
|
4421
|
+
}]);
|
|
4422
|
+
if (!installedComments || installedComments.length !== 1
|
|
4423
|
+
|| canonicalWorkspaceSyncJson(installedComments[0])
|
|
4424
|
+
!== canonicalWorkspaceSyncJson(intent.comment)) {
|
|
4425
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
4426
|
+
}
|
|
4427
|
+
return true;
|
|
4428
|
+
}
|
|
4429
|
+
prepareTaskRecoveryPayload(tenantId, workspaceId, source) {
|
|
4430
|
+
let payload;
|
|
4431
|
+
try {
|
|
4432
|
+
payload = JSON.parse(String(source.payload_json || ''));
|
|
4433
|
+
}
|
|
4434
|
+
catch {
|
|
4435
|
+
throw new Error('Workspace sync recovery payload is not valid JSON.');
|
|
4436
|
+
}
|
|
4437
|
+
if (fingerprintWorkspaceSyncPayload(payload) !== source.payload_fingerprint) {
|
|
4438
|
+
throw new Error('Workspace sync recovery payload fingerprint does not match.');
|
|
4439
|
+
}
|
|
4440
|
+
if (source.mutation_kind !== 'set-status' || !payload.workflowAssignment) {
|
|
4441
|
+
return {
|
|
4442
|
+
payloadJson: canonicalWorkspaceSyncJson(payload),
|
|
4443
|
+
payloadFingerprint: fingerprintWorkspaceSyncPayload(payload),
|
|
4444
|
+
workflowDependencyCoalesced: false,
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4447
|
+
const repairableCommentEvidenceConflict = (source.last_error_code === 'WORKFLOW_ASSIGNMENT_SYNC_CONFLICT'
|
|
4448
|
+
&& source.last_error_message === 'Workflow assignment published steps conflict with canonical storage.');
|
|
4449
|
+
const normalizedAssignment = repairableCommentEvidenceConflict
|
|
4450
|
+
? this.normalizeRecoveredWorkflowStepEvidence(tenantId, workspaceId, payload.workflowAssignment)
|
|
4451
|
+
: null;
|
|
4452
|
+
const workflowDefinitionNormalized = normalizedAssignment?.changed === true;
|
|
4453
|
+
if (workflowDefinitionNormalized) {
|
|
4454
|
+
payload.workflowAssignment = normalizedAssignment.assignment;
|
|
4455
|
+
}
|
|
4456
|
+
const requested = payload.workflowAssignment;
|
|
4457
|
+
const requestedExecution = requested.execution;
|
|
4458
|
+
const requestedEvents = Array.isArray(requested.events) ? requested.events : [];
|
|
4459
|
+
const executionId = String(requestedExecution?.id || '');
|
|
4460
|
+
if (!executionId
|
|
4461
|
+
|| !requested.template || typeof requested.template !== 'object'
|
|
4462
|
+
|| !requested.version || typeof requested.version !== 'object'
|
|
4463
|
+
|| !Array.isArray(requested.steps)
|
|
4464
|
+
|| !requestedExecution?.taskId
|
|
4465
|
+
|| !requestedExecution.workflowTemplateId
|
|
4466
|
+
|| !requestedExecution.workflowVersionId
|
|
4467
|
+
|| requestedEvents.length === 0) {
|
|
4468
|
+
throw new Error('Workflow assignment recovery requires semantic history reconciliation.');
|
|
4469
|
+
}
|
|
4470
|
+
const accepted = this.db.prepare(`
|
|
4471
|
+
SELECT payload_json, authoritative_response_json
|
|
4472
|
+
FROM workspace_sync_outbox
|
|
4473
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
4474
|
+
AND domain = 'task' AND entity_id = ?
|
|
4475
|
+
AND mutation_kind = 'set-status' AND status = 'accepted'
|
|
4476
|
+
AND enqueue_sequence < ?
|
|
4477
|
+
ORDER BY enqueue_sequence DESC
|
|
4478
|
+
`).all(tenantId, workspaceId, requireText(source.entity_id, 'Source entity id'), normalizeSequence(source.enqueue_sequence ?? 0));
|
|
4479
|
+
const immutableExecution = (execution) => ({
|
|
4480
|
+
id: execution?.id,
|
|
4481
|
+
taskId: execution?.taskId,
|
|
4482
|
+
workflowTemplateId: execution?.workflowTemplateId,
|
|
4483
|
+
workflowVersionId: execution?.workflowVersionId,
|
|
4484
|
+
});
|
|
4485
|
+
const dependencySatisfied = accepted.some((candidate) => {
|
|
4486
|
+
try {
|
|
4487
|
+
const acceptedPayload = JSON.parse(String(candidate.payload_json || ''));
|
|
4488
|
+
const acceptedResponse = JSON.parse(String(candidate.authoritative_response_json || ''));
|
|
4489
|
+
const acceptedRequest = acceptedPayload.workflowAssignment;
|
|
4490
|
+
const authoritative = acceptedResponse.workflowAssignment;
|
|
4491
|
+
const authoritativeExecution = authoritative?.execution;
|
|
4492
|
+
const authoritativeEvents = Array.isArray(authoritative?.events) ? authoritative.events : [];
|
|
4493
|
+
return String(acceptedRequest?.execution?.id || '')
|
|
4494
|
+
=== executionId
|
|
4495
|
+
&& canonicalWorkspaceSyncJson(requested.template)
|
|
4496
|
+
=== canonicalWorkspaceSyncJson(authoritative?.template)
|
|
4497
|
+
&& canonicalWorkspaceSyncJson(requested.version)
|
|
4498
|
+
=== canonicalWorkspaceSyncJson(authoritative?.version)
|
|
4499
|
+
&& canonicalWorkspaceSyncJson(requested.steps)
|
|
4500
|
+
=== canonicalWorkspaceSyncJson(authoritative?.steps)
|
|
4501
|
+
&& canonicalWorkspaceSyncJson(immutableExecution(requestedExecution))
|
|
4502
|
+
=== canonicalWorkspaceSyncJson(immutableExecution(authoritativeExecution))
|
|
4503
|
+
&& authoritativeExecution?.status !== 'pending'
|
|
4504
|
+
&& authoritativeEvents.length >= requestedEvents.length
|
|
4505
|
+
&& canonicalWorkspaceSyncJson(authoritativeEvents.slice(0, requestedEvents.length))
|
|
4506
|
+
=== canonicalWorkspaceSyncJson(requestedEvents);
|
|
4507
|
+
}
|
|
4508
|
+
catch {
|
|
4509
|
+
return false;
|
|
4510
|
+
}
|
|
4511
|
+
});
|
|
4512
|
+
if (!dependencySatisfied) {
|
|
4513
|
+
if (workflowDefinitionNormalized) {
|
|
4514
|
+
return {
|
|
4515
|
+
payloadJson: canonicalWorkspaceSyncJson(payload),
|
|
4516
|
+
payloadFingerprint: fingerprintWorkspaceSyncPayload(payload),
|
|
4517
|
+
workflowDependencyCoalesced: false,
|
|
4518
|
+
};
|
|
4519
|
+
}
|
|
4520
|
+
throw new Error('Workflow assignment recovery requires semantic history reconciliation.');
|
|
4521
|
+
}
|
|
4522
|
+
delete payload.workflowAssignment;
|
|
4523
|
+
return {
|
|
4524
|
+
payloadJson: canonicalWorkspaceSyncJson(payload),
|
|
4525
|
+
payloadFingerprint: fingerprintWorkspaceSyncPayload(payload),
|
|
4526
|
+
workflowDependencyCoalesced: true,
|
|
4527
|
+
};
|
|
4528
|
+
}
|
|
4529
|
+
allocateSequenceRange(tenantId, workspaceId, now, count) {
|
|
4530
|
+
const row = this.db.prepare(`
|
|
4531
|
+
INSERT INTO workspace_sync_sequences (tenant_id, workspace_id, last_sequence, updated_at)
|
|
4532
|
+
VALUES (?, ?, ?, ?)
|
|
4533
|
+
ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
|
|
4534
|
+
last_sequence = workspace_sync_sequences.last_sequence + excluded.last_sequence,
|
|
4535
|
+
updated_at = excluded.updated_at
|
|
4536
|
+
RETURNING CAST(last_sequence AS TEXT) AS last_sequence
|
|
4537
|
+
`).get(tenantId, workspaceId, count, now);
|
|
4538
|
+
return normalizeSequence(row?.last_sequence);
|
|
4539
|
+
}
|
|
4540
|
+
allocateOutboxEnqueueSequence(tenantId, workspaceId, now) {
|
|
4541
|
+
const row = this.db.prepare(`
|
|
4542
|
+
INSERT INTO workspace_sync_outbox_sequences (tenant_id, workspace_id, last_sequence, updated_at)
|
|
4543
|
+
VALUES (?, ?, 1, ?)
|
|
4544
|
+
ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
|
|
4545
|
+
last_sequence = workspace_sync_outbox_sequences.last_sequence + 1,
|
|
4546
|
+
updated_at = excluded.updated_at
|
|
4547
|
+
RETURNING CAST(last_sequence AS TEXT) AS last_sequence
|
|
4548
|
+
`).get(tenantId, workspaceId, now);
|
|
4549
|
+
return normalizeSequence(row?.last_sequence);
|
|
4550
|
+
}
|
|
4551
|
+
readOperationRow(ids) {
|
|
4552
|
+
return this.db.prepare(`
|
|
4553
|
+
SELECT contract_version, request_fingerprint, status, response_json,
|
|
4554
|
+
CAST(accepted_sequence AS TEXT) AS accepted_sequence,
|
|
4555
|
+
lease_owner, lease_expires_at
|
|
4556
|
+
FROM workspace_sync_operation_results
|
|
4557
|
+
WHERE tenant_id = ? AND workspace_id = ? AND client_id = ? AND operation_id = ?
|
|
4558
|
+
`).get(...ids);
|
|
4559
|
+
}
|
|
4560
|
+
assertMatchingOperation(row, requestFingerprint) {
|
|
4561
|
+
if (!row)
|
|
4562
|
+
throw new Error('Workspace sync operation does not exist.');
|
|
4563
|
+
if (Number(row.contract_version) !== WORKSPACE_SYNC_CONTRACT_VERSION || row.request_fingerprint !== requestFingerprint) {
|
|
4564
|
+
throw new WorkspaceSyncIdempotencyConflictError();
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
4567
|
+
mapOutboxRow(row) {
|
|
4568
|
+
return {
|
|
4569
|
+
tenantId: requireText(row.tenant_id, 'Tenant id'),
|
|
4570
|
+
workspaceId: requireText(row.workspace_id, 'Workspace id'),
|
|
4571
|
+
clientId: requireText(row.client_id, 'Client id'),
|
|
4572
|
+
operationId: requireText(row.operation_id, 'Operation id'),
|
|
4573
|
+
dispatchClass: requireOutboxDispatchClass(row.dispatch_class),
|
|
4574
|
+
domain: requireText(row.domain, 'Domain'),
|
|
4575
|
+
entityId: requireText(row.entity_id, 'Entity id'),
|
|
4576
|
+
mutationKind: requireText(row.mutation_kind, 'Mutation kind'),
|
|
4577
|
+
ownsTaskMetadata: Number(row.owns_task_metadata) === 1,
|
|
4578
|
+
ownsTaskLifecycle: Number(row.owns_task_lifecycle) === 1,
|
|
4579
|
+
ownsTaskAssignee: Number(row.owns_task_assignee) === 1,
|
|
4580
|
+
baseEntityRevision: normalizeSequence(row.base_entity_revision),
|
|
4581
|
+
baseLifecycleRevision: normalizeSequence(row.base_lifecycle_revision),
|
|
4582
|
+
payloadJson: requireText(row.payload_json, 'Payload'),
|
|
4583
|
+
payloadFingerprint: requireText(row.payload_fingerprint, 'Payload fingerprint'),
|
|
4584
|
+
status: requireText(row.status, 'Outbox status'),
|
|
4585
|
+
attemptCount: Number(row.attempt_count || 0),
|
|
4586
|
+
initialNotFoundAttemptCount: Number(row.initial_not_found_attempt_count || 0),
|
|
4587
|
+
enqueueSequence: normalizeSequence(row.enqueue_sequence ?? 0),
|
|
4588
|
+
rebaseBeforeFirstAttempt: Number(row.rebase_before_first_attempt) === 1,
|
|
4589
|
+
workstreamMetadataRevisionRebaseCount: Number(row.workstream_metadata_revision_rebase_count || 0),
|
|
4590
|
+
initiativeMetadataRevisionRebaseCount: Number(row.initiative_metadata_revision_rebase_count || 0),
|
|
4591
|
+
leaseOwner: row.lease_owner == null ? null : String(row.lease_owner),
|
|
4592
|
+
leaseExpiresAt: row.lease_expires_at == null ? null : String(row.lease_expires_at),
|
|
4593
|
+
};
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
// Consistent reservation lifecycle for a V3 mutation service: reserve, run
|
|
4597
|
+
// arbitrary caller work (readiness/identity checks, then an accept/commit
|
|
4598
|
+
// call), and release the reservation immediately if that work throws before
|
|
4599
|
+
// committing. Without this, a business-logic failure between reserveOperation
|
|
4600
|
+
// and acceptReservedOperationBatch/commitReservedOperationResponse leaves the
|
|
4601
|
+
// reservation row orphaned in status='processing' until its lease naturally
|
|
4602
|
+
// expires, so every retry sees WORKSPACE_SYNC_OPERATION_IN_PROGRESS instead of
|
|
4603
|
+
// re-attempting. acceptReservedOperationBatch and commitReservedOperationResponse
|
|
4604
|
+
// already self-release on a throw from inside their own transaction (the most
|
|
4605
|
+
// common case, ordinary business validation inside applyMutation); this helper
|
|
4606
|
+
// additionally covers a service's own pre-accept work, exactly like the
|
|
4607
|
+
// TASK_ATTACHMENT_ASSET_NOT_READY readiness check for task-attachment-links.
|
|
4608
|
+
// Never deletes, rewrites, or force-accepts the operation: on release, the
|
|
4609
|
+
// exact same operation identity is immediately eligible for reservation
|
|
4610
|
+
// takeover on the next call with the same request fingerprint.
|
|
4611
|
+
export function runReservedWorkspaceSyncV3Operation(input) {
|
|
4612
|
+
const reservation = input.store.reserveOperation({
|
|
4613
|
+
identity: input.identity,
|
|
4614
|
+
requestFingerprint: input.requestFingerprint,
|
|
4615
|
+
now: input.now,
|
|
4616
|
+
leaseExpiresAt: input.leaseExpiresAt,
|
|
4617
|
+
leaseOwner: input.leaseOwner,
|
|
4618
|
+
});
|
|
4619
|
+
if (reservation.state !== 'owned')
|
|
4620
|
+
return reservation;
|
|
4621
|
+
try {
|
|
4622
|
+
return input.attempt(reservation);
|
|
4623
|
+
}
|
|
4624
|
+
catch (error) {
|
|
4625
|
+
if (!(error instanceof WorkspaceSyncLeaseLostError)) {
|
|
4626
|
+
input.store.releaseFailedOperationReservation({
|
|
4627
|
+
identity: input.identity,
|
|
4628
|
+
requestFingerprint: input.requestFingerprint,
|
|
4629
|
+
leaseOwner: reservation.leaseOwner,
|
|
4630
|
+
now: input.now,
|
|
4631
|
+
});
|
|
4632
|
+
}
|
|
4633
|
+
throw error;
|
|
4634
|
+
}
|
|
4635
|
+
}
|