import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; import { CAPABILITY_UNAVAILABLE_REASONS, isReplicationCompleted, NO_INTERACTION, type ContinuousReplicationRequest, type ReplicationOutcome, type UnattendedOneShotRequest, } from "@vrtmrz/livesync-commonlib/replication"; import { PeriodicProcessor } from "@/common/PeriodicProcessor"; type ReplicationSchedulingSettings = Pick< ObsidianLiveSyncSettings, "isConfigured" | "liveSync" | "syncOnStart" | "periodicReplication" | "periodicReplicationInterval" >; /** Timer operations required by the scheduling state owner. */ export interface ReplicationSchedulingTimer { enable(intervalMs: number): void; disable(): void; } /** Daemon-only controls which do not expose mutable scheduling state. */ export interface ReplicationSchedulingControl { /** Let an external daemon poller become, or cease to be, the recurring-work owner. */ setExternalPollingMode(enabled: boolean): void; /** Consume the next resume-triggered OneShot because the daemon has already converged once. */ markInitialOneShotSatisfied(): void; } interface ReplicationSchedulingDependencies { isReady(): boolean; isSuspended(): boolean; currentSettings(): ReplicationSchedulingSettings; replicateUnattended(request: UnattendedOneShotRequest): Promise; startContinuous(request: ContinuousReplicationRequest): Promise; timer: ReplicationSchedulingTimer; log(error: unknown): void; } interface ReplicationSchedulingState { externalPolling: boolean; continuousOwnsRecurring: boolean; initialOneShotSatisfied: boolean; lifecycleAllowsScheduling: boolean; lifecycleGeneration: number; resumeOperation: Promise | undefined; runningResumeGeneration: number | undefined; queuedResumeGeneration: number | undefined; } /** Private state and collaborators owned by the replication scheduling serviceFeature. */ interface ReplicationSchedulingContext { readonly dependencies: ReplicationSchedulingDependencies; readonly state: ReplicationSchedulingState; } function isCapabilityUnavailable(result: ReplicationOutcome): boolean { return ( result.status === "blocked" && (result.reason === CAPABILITY_UNAVAILABLE_REASONS.NOT_APPLICABLE || result.reason === CAPABILITY_UNAVAILABLE_REASONS.NOT_IMPLEMENTED) ); } /** Construct the independently testable context owned by the serviceFeature. */ export function createReplicationSchedulingContext( dependencies: ReplicationSchedulingDependencies ): ReplicationSchedulingContext { return { dependencies, state: { externalPolling: false, continuousOwnsRecurring: false, initialOneShotSatisfied: false, // AppLifecycleService does not expose physical visibility as // isSuspended(). Keep the observed state in this private context. lifecycleAllowsScheduling: false, lifecycleGeneration: 0, resumeOperation: undefined, runningResumeGeneration: undefined, queuedResumeGeneration: undefined, }, }; } function canRunPeriodic(context: ReplicationSchedulingContext, settings: ReplicationSchedulingSettings): boolean { const { dependencies, state } = context; return ( state.lifecycleAllowsScheduling && !state.externalPolling && !state.continuousOwnsRecurring && dependencies.isReady() && !dependencies.isSuspended() && settings.isConfigured === true && settings.periodicReplication === true ); } function reconcilePeriodic(context: ReplicationSchedulingContext): void { const { dependencies } = context; const settings = dependencies.currentSettings(); if (canRunPeriodic(context, settings)) { dependencies.timer.enable(settings.periodicReplicationInterval * 1000); } else { dependencies.timer.disable(); } } function setContinuousOwnership(context: ReplicationSchedulingContext, ownsRecurring: boolean): void { const { state } = context; if (state.continuousOwnsRecurring === ownsRecurring) return; state.continuousOwnsRecurring = ownsRecurring; reconcilePeriodic(context); } function isCurrentLifecycleGeneration(context: ReplicationSchedulingContext, generation: number): boolean { return generation === context.state.lifecycleGeneration; } function canRunResume(context: ReplicationSchedulingContext, generation: number): boolean { const { dependencies, state } = context; return ( isCurrentLifecycleGeneration(context, generation) && state.lifecycleAllowsScheduling && !state.externalPolling && dependencies.isReady() && !dependencies.isSuspended() ); } async function runAfterResume(context: ReplicationSchedulingContext, generation: number): Promise { if (!canRunResume(context, generation)) return; const { dependencies, state } = context; const settings = dependencies.currentSettings(); if (!settings.isConfigured) { setContinuousOwnership(context, false); return; } const skipOneShot = state.initialOneShotSatisfied; // This marker belongs to one resume attempt. Consume it before any network // await so an exceptional Continuous start cannot suppress a later retry. state.initialOneShotSatisfied = false; if (settings.liveSync) { setContinuousOwnership(context, true); let result: ReplicationOutcome; try { result = await dependencies.startContinuous({ trigger: "resume", interaction: NO_INTERACTION, }); } catch (error) { if (isCurrentLifecycleGeneration(context, generation)) { setContinuousOwnership(context, false); } throw error; } if (!isReplicationCompleted(result) && isCurrentLifecycleGeneration(context, generation)) { setContinuousOwnership(context, false); } // A suspend/resume may have started a new lifecycle generation while // Continuous was settling. Do not let the obsolete result schedule a // finite fallback for the new generation. if (isCapabilityUnavailable(result) && canRunResume(context, generation)) { const currentSettings = dependencies.currentSettings(); if ( currentSettings.isConfigured && currentSettings.liveSync && currentSettings.syncOnStart && !skipOneShot ) { await dependencies.replicateUnattended({ trigger: "resume", interaction: NO_INTERACTION, }); } } return; } setContinuousOwnership(context, false); if (settings.syncOnStart && !skipOneShot) { await dependencies.replicateUnattended({ trigger: "resume", interaction: NO_INTERACTION, }); } } function scheduleAfterResume(context: ReplicationSchedulingContext): void { const { dependencies, state } = context; const requestedGeneration = state.lifecycleGeneration; if (state.resumeOperation) { // Duplicate notifications within one generation share the current // operation. A later lifecycle generation must run after it. if (state.runningResumeGeneration !== requestedGeneration) { state.queuedResumeGeneration = requestedGeneration; } return; } state.runningResumeGeneration = requestedGeneration; state.resumeOperation = runAfterResume(context, requestedGeneration) .catch((error: unknown) => { dependencies.log(error); }) .finally(() => { state.resumeOperation = undefined; state.runningResumeGeneration = undefined; const queuedGeneration = state.queuedResumeGeneration; state.queuedResumeGeneration = undefined; if (queuedGeneration === state.lifecycleGeneration && state.lifecycleAllowsScheduling) { scheduleAfterResume(context); } }); } /** Schedule eligible work after the application has resumed. */ export function resumeReplicationScheduling(context: ReplicationSchedulingContext): void { const { state } = context; if (!state.lifecycleAllowsScheduling) { state.lifecycleGeneration += 1; } state.lifecycleAllowsScheduling = true; // runAfterResume executes synchronously until its first await. A Continuous // request therefore reserves ownership before Periodic is reconciled. scheduleAfterResume(context); reconcilePeriodic(context); } /** Stop generic Periodic scheduling before the application suspends. */ export function suspendReplicationScheduling(context: ReplicationSchedulingContext): void { context.state.lifecycleAllowsScheduling = false; context.state.queuedResumeGeneration = undefined; context.dependencies.timer.disable(); } /** Stop generic Periodic scheduling while settings and provider bindings change. */ export function prepareReplicationSchedulingForSettings(context: ReplicationSchedulingContext): void { context.dependencies.timer.disable(); } /** Reconcile generic Periodic scheduling after settings have settled. */ export function realiseReplicationScheduling(context: ReplicationSchedulingContext): void { reconcilePeriodic(context); } /** Prevent later timer callbacks from scheduling new work during unload. */ export function unloadReplicationScheduling(context: ReplicationSchedulingContext): void { context.state.lifecycleAllowsScheduling = false; context.state.queuedResumeGeneration = undefined; context.dependencies.timer.disable(); } /** Execute one timer callback if Periodic still owns recurring work. */ export async function runPeriodicReplication(context: ReplicationSchedulingContext): Promise { const { dependencies } = context; // Clearing an interval does not retract a callback which is already queued. // Recheck ownership and lifecycle state at execution time. if (!canRunPeriodic(context, dependencies.currentSettings())) return; await dependencies.replicateUnattended({ trigger: "periodic", interaction: NO_INTERACTION, }); } /** Declare that an external poller has become, or ceased to be, the recurring-work owner. */ export function setExternalPollingMode(context: ReplicationSchedulingContext, enabled: boolean): void { if (context.state.externalPolling === enabled) return; context.state.externalPolling = enabled; reconcilePeriodic(context); } /** Consume the next resume-triggered OneShot because the daemon has already converged once. */ export function markInitialOneShotSatisfied(context: ReplicationSchedulingContext): void { context.state.initialOneShotSatisfied = true; } type ReplicationSchedulingHost = NecessaryServices< "API" | "appLifecycle" | "control" | "replication" | "setting", never >; type ReplicationSchedulingTimerFactory = (process: () => Promise) => ReplicationSchedulingTimer; /** * Compose host lifecycle bindings around one private scheduling context. * * The returned view is intentionally limited to daemon scheduling controls. * @param host Narrow service container used to bind scheduling to the host lifecycle. * @param createTimer Timer adapter factory, replaceable by focused tests. * @returns Commands which let the CLI daemon declare its scheduling ownership. */ export function useReplicationScheduling( host: ReplicationSchedulingHost, createTimer: ReplicationSchedulingTimerFactory = (process) => new PeriodicProcessor(host, process) ): ReplicationSchedulingControl { const services = host.services; const log = createInstanceLogFunction("SF:ReplicationScheduling", services.API); let context!: ReplicationSchedulingContext; const timer = createTimer(async () => await runPeriodicReplication(context)); context = createReplicationSchedulingContext({ isReady: () => services.appLifecycle.isReady(), isSuspended: () => services.appLifecycle.isSuspended(), currentSettings: () => services.setting.currentSettings(), replicateUnattended: (request) => services.replication.replicateUnattended(request), startContinuous: (request) => services.replication.startContinuous(request), timer, log: (error) => log(error, LOG_LEVEL_VERBOSE), }); services.appLifecycle.onUnload.addHandler(() => { unloadReplicationScheduling(context); return Promise.resolve(true); }); services.setting.onBeforeRealiseSetting.addHandler(() => { prepareReplicationSchedulingForSettings(context); return Promise.resolve(true); }); services.setting.onSettingRealised.addHandler(() => { realiseReplicationScheduling(context); return Promise.resolve(true); }); services.appLifecycle.onSuspending.addHandler(() => { suspendReplicationScheduling(context); return Promise.resolve(true); }); services.appLifecycle.onResumed.addHandler(() => { resumeReplicationScheduling(context); return Promise.resolve(true); }); return Object.freeze({ setExternalPollingMode: (enabled: boolean) => setExternalPollingMode(context, enabled), markInitialOneShotSatisfied: () => markInitialOneShotSatisfied(context), }); }