/** * Test→Prod Promotion State Machine — Store + Engine (Phase 4 roadmap 4.4) * * Persists per plugin+version promotion records to * ~/.pluginator/promotion-state.json (atomic tmp+rename writes) and drives * the EXISTING sync workflow — restricted to exactly the promotable plugins * via SyncOptions.includePlugins — for the actual prod-bound copy. Sync * brings its own safety artifacts (backup archive before mutation, journal, * workflow lock). * * ## Guard rails (every refusal/skip carries a reason — no silent failure) * * - PROD-running refusal: promotion never mutates a live prod server. * Staged-mode promotion through sync is NOT implemented — the run refuses * with the reason instead of pretending (honest beats clever; revisit with * Phase 5 AMP restart orchestration). * - TEST-dir verification before any copy: the soaked version must actually * be the enabled jar in the test plugins dir. A 'staged-for-restart' * artifact still sitting in plugins/update/ can therefore never cause the * OLD jar to be promoted under the new version's name. * - Maintenance window: SCHEDULED promotions refuse outside the window; * manual promotions proceed with an explicit note. * - Exclude list (ScheduleConfig.promotionExclude): excluded plugins soak * forever, visibly. * * ## Phase 5 contract: quarantineOnFailure * * {@link PromotionEngine.quarantineOnFailure} is the hook for post-restart * verification (C1 exposed the verify function; AMP restart events land in * Phase 5): when a restarted test server does NOT load the expected version, * the watcher calls `quarantineOnFailure(pluginName, reason)` and the record * leaves the promotion track until a newer version supersedes it. Quarantine * is refused for already-promoted records — roll back instead. * * ## Design note: sync + swallow persistence is intentional * * Same precedent as ProvenanceStore/NotificationStore: a corrupt or missing * state file loads as an EMPTY store with a logged warning and never crashes * an install. Consequence (documented, accepted): corruption resets soak * clocks — plugins re-enter 'soaking' on their next install event, which * fails SAFE (nothing promotes early; promotion is delayed, not wrong). * * @since v2.12.7 (roadmap Phase 4, assignment D1) */ import { type PromoteOptions, type PromoteResult, type PromotionEvaluation, type PromotionPolicy, type PromotionRunReport, type PromotionStatus, type QuarantineResult, type SupersedeResult, type TestInstallEvent } from './promotion-types.js'; /** Bounded history: most recent N records kept per plugin */ export declare const MAX_HISTORY_PER_PLUGIN = 10; /** Minimal scan shape the engine needs from the test plugins dir */ export interface ScannedTestPlugin { name: string; version: string; disabled?: boolean; } /** Sync request the engine hands to its sync driver */ export interface PromotionSyncRequest { prodDir: string; testDir: string; /** EXACT restriction list — sync must not touch anything else */ includePlugins: string[]; dryRun: boolean; } /** Outcome shape the engine consumes from the sync driver */ export interface PromotionSyncOutcome { copied: string[]; skipped: string[]; errors: Array<{ pluginName: string; error: string; }>; backupPath?: string; } /** * Injectable collaborators (tests). Defaults drive the real machinery: * SyncWorkflow test→prod (dynamic import keeps core free of a static * workflows dependency), isPluginsDirOfRunningServer, scanDirectory. */ export interface PromotionEngineDeps { runSync?: (request: PromotionSyncRequest) => Promise; isProdServerRunning?: (prodDir: string) => Promise; scanTestDir?: (testDir: string) => Promise; } export declare class PromotionEngine { private data; private storePath; private loaded; private deps; constructor(options?: { storePath?: string; deps?: PromotionEngineDeps; }); /** * Load records from disk. Corrupt/missing file → empty store + warning, * never throws (sync+swallow precedent; see module doc for the fail-safe * consequence). */ load(): void; private save; /** * Record an artifact landing on the TEST server. Called from the ONE * shared install-success path (download.ts recordSuccessfulInstall, which * auto-update / downloadBatch / complete-manual all flow through). * * Transitions: * - New version (or same version with different bytes): fresh 'soaking' * record at the head; prior soaking/promotable records → 'superseded'. * - Identical artifact re-installed (same version + same sha256): no state * change — the soak clock measures exposure of those exact bytes, and a * re-download neither clears a quarantine nor restarts an honest soak. */ recordTestInstall(event: TestInstallEvent): void; /** * Quarantine the current record (allowed from anywhere PRE-promotion). * Promoted records refuse — the damage is already on prod; roll back * instead. Superseded records refuse — they are no longer on the track. */ quarantine(pluginName: string, reason: string): QuarantineResult; /** * Phase 5 contract: post-restart verification calls this when the test * server fails to load the expected version after a restart (see module * doc). Identical semantics to {@link quarantine} — named separately so the * wiring intent survives in call sites. */ quarantineOnFailure(pluginName: string, reason: string): QuarantineResult; /** * Rollback integration (Phase 5 — live-E2E find): rolling a plugin back on * the TEST server leaves its 'soaking'/'promotable' record pointing at a * version that is no longer in the test dir. The scheduled promotion would * later try to promote that stale state — promote()'s test-dir verification * would refuse, but the store keeps lying until then. The rollback workflow * calls this with the REPLACED version (the update the rollback removed) to * mark the head record superseded with an explicit note. * * Only soaking/promotable records transition. Quarantined records stay * quarantined (they never promote, and the quarantine reason carries more * information than a supersede note); promoted records refuse (the version * is already on prod — a test rollback does not undo prod state). */ supersedeOnRollback(pluginName: string, version: string, note: string): SupersedeResult; /** * Classify every current record against the policy, transitioning * soaking→promotable (persisted) when the soak window elapsed and the * plugin is not excluded. Excluded plugins NEVER transition — they appear * in `soaking` with `excluded: true`, forever, by design. */ evaluatePromotions(policy?: PromotionPolicy, nowMs?: number): PromotionEvaluation; private toSoakingEntry; /** * Promote the named plugins to PROD by driving the existing sync workflow * restricted to EXACTLY the eligible names (SyncOptions.includePlugins). * See the module doc for the guard rails; every requested name lands in * promoted/failed/skipped with a reason, or the whole run is `refused`. */ promote(names: string[], options: PromoteOptions): Promise; /** Full store view: newest record per plugin + bounded history */ getStatus(): PromotionStatus; } /** * Get or create the global promotion engine. * @param options.storePath Pins the state file on FIRST call (tests use a temp dir) * @param options.deps Pins collaborators on FIRST call (tests inject mocks) */ export declare function getPromotionEngine(options?: { storePath?: string; deps?: PromotionEngineDeps; }): PromotionEngine; /** Reset the global promotion engine (mainly for testing) */ export declare function resetPromotionEngine(): void; /** Injection points for {@link maybeRunScheduledPromotion} (tests) */ export interface ScheduledPromotionOptions { /** PROD plugins directory */ prodDir: string; /** TEST plugins directory */ testDir: string; /** Override the schedule-state file (default ~/.pluginator/schedule-state.json) */ scheduleStatePath?: string; /** Override the engine (default: singleton) */ engine?: PromotionEngine; /** Clock override (ms since epoch) */ nowMs?: number; } /** * The optional promotion step on the scheduled run path (roadmap 4.4): * `pluginator auto-update --json` (the command every generated scheduler * entry invokes) calls this after its check/download phase. * * Returns null — and does NOTHING — unless `promotionEnabled` is true in the * persisted ScheduleConfig (default OFF). When enabled: evaluates soak * states, promotes promotable plugins within the maintenance window * (scheduled semantics — outside the window the run refuses), logs, and * emits through the existing schedule-notification path when anything * happened (the Headless agent wires the Discord channel onto that path). */ export declare function maybeRunScheduledPromotion(options: ScheduledPromotionOptions): Promise; //# sourceMappingURL=promotion-engine.d.ts.map