fix(drift): reconcile the drift ledger on deploy and timestamp its history (#1405)

* fix(drift): reconcile the drift ledger on deploy and timestamp its history

The drift ledger (persisted history + activity timeline) only advanced
when someone clicked re-check on a stack's Drift tab, so the history could
sit indefinitely out of sync with the live status: a stack reading
"drifted" live while its history still said "resolved". Two corrections:

- Deploy and update reconcile the ledger against the just-deployed runtime
  (the rollback route re-deploys through deployStack, so it is covered),
  resolving what the change fixed and recording what it left.
- Every authoritative reconcile stamps the dossier last-checked time, and
  the Drift tab labels its history "checked {time}" so a stale finding
  reads as history, not a claim about the live status above it.

Adds the last_drift_check_at column and tests across the ledger reconcile
stamp, reconcileStack, the deploy hook, and the panel.

* fix(drift): stamp last-checked inside the ledger transaction

Move the dossier last-checked stamp into the same transaction as the
finding insert/resolve, so the "checked {time}" the Drift tab shows can
never persist without the ledger update it describes. The stamp still runs
on a no-op authoritative check (a transaction that only stamps), keeping
the history "as of" honest. Adds a test that a failed deploy does not
reconcile the ledger.
This commit is contained in:
Anso
2026-06-21 18:20:57 -04:00
committed by GitHub
parent b9d8e9f490
commit f9c6c5fd09
9 changed files with 193 additions and 10 deletions
+10 -1
View File
@@ -455,6 +455,13 @@ export class ComposeService {
// route: bulk, Git-source, App Store, scheduler, and webhook deploys all funnel
// through this method. Internally guarded; awaited so it cannot race later work.
await DriftLedgerService.getInstance().recordBaseline(this.nodeId, stackName);
// Reconcile the ledger against the just-deployed runtime: findings this deploy
// fixed are resolved and any it left are recorded (and surfaced in the activity
// feed) now, instead of waiting for someone to open the Drift tab. The rollback
// route re-deploys through this method, so it is covered; a failed atomic deploy
// instead restores the previous files and throws above, so that recovery path
// reconciles on its next deploy or scan, not here. Best-effort internally.
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
}
streamLogs(stackName: string, ws: WebSocket) {
@@ -652,8 +659,10 @@ export class ComposeService {
throw updateError;
}
// Reached only on a successful update; re-baseline so temporal drift compares
// against what is now deployed (see deployStack for why this lives here).
// against what is now deployed (see deployStack for why this lives here), then
// reconcile the ledger against the updated runtime.
await DriftLedgerService.getInstance().recordBaseline(this.nodeId, stackName);
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
}
public async downStack(stackName: string): Promise<void> {
+20
View File
@@ -90,6 +90,8 @@ export interface StackDossier extends StackDossierFields {
source_hash?: string | null;
/** SHA-256 of the parsed compose model at the last deploy (ignores comments/whitespace). */
rendered_hash?: string | null;
/** When the drift ledger was last reconciled for this stack (re-check, deploy, or background scan); null if never. */
last_drift_check_at?: number | null;
created_at: number;
updated_at: number;
}
@@ -1284,6 +1286,7 @@ export class DatabaseService {
custom_notes TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_drift_check_at INTEGER,
UNIQUE(node_id, stack_name)
);
@@ -1669,6 +1672,7 @@ export class DatabaseService {
private migrateStackDossierHashes(): void {
this.tryAddColumn('stack_dossiers', 'source_hash', 'TEXT');
this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER');
}
private migrateGitSourceMultiFile(): void {
@@ -2338,6 +2342,22 @@ export class DatabaseService {
).run(nodeId, stackName, sourceHash, renderedHash, now, now);
}
/**
* Stamp when the drift ledger was last reconciled for a stack (re-check, deploy,
* or background scan). Mirrors setStackDossierHashes: creates a notes-empty row
* if none exists, otherwise updates only this column so operator notes and their
* updated_at are left untouched.
*/
public setStackDossierDriftCheck(nodeId: number, stackName: string, checkedAt: number): void {
const now = Date.now();
this.db.prepare(
`INSERT INTO stack_dossiers (node_id, stack_name, last_drift_check_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET
last_drift_check_at = excluded.last_drift_check_at`
).run(nodeId, stackName, checkedAt, now, now);
}
// --- Stack Drift Findings (the persisted drift ledger) ---
public insertDriftFinding(f: Omit<StackDriftFindingRow, 'id' | 'resolved_at'>): number {
+25 -5
View File
@@ -6,6 +6,7 @@ import type { DeclaredCompose } from '../helpers/composeDependencyParse';
import { sha256Hex } from '../utils/hashing';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { buildStackDriftReport } from './DriftDetectionService';
import type { StackDriftReport, StackDriftFinding } from './DriftDetectionService';
/**
@@ -130,6 +131,7 @@ export class DriftLedgerService {
return { detected: 0, resolved: 0 };
}
const db = DatabaseService.getInstance();
const now = Date.now();
const openByKey = new Map(db.getOpenDriftFindings(nodeId, stackName).map(r => [findingKey(r.service, r.finding_type), r]));
const currentByKey = new Map(report.findings.map(f => [findingKey(f.service, f.kind), f]));
@@ -141,12 +143,13 @@ export class DriftLedgerService {
for (const [key, row] of openByKey) {
if (!currentByKey.has(key)) toResolve.push(row);
}
if (toInsert.length === 0 && toResolve.length === 0) {
return { detected: 0, resolved: 0 };
}
const now = Date.now();
// Stamp the check time and apply any transitions in one transaction, so the
// "checked {time ago}" the Drift tab shows can never persist without the ledger
// update it describes. The stamp runs even on a no-op authoritative check (no
// transitions), so the history's "as of" stays honest while a stale finding
// reads as history rather than as live truth.
db.getDb().transaction(() => {
db.setStackDossierDriftCheck(nodeId, stackName, now);
for (const f of toInsert) {
db.insertDriftFinding({
node_id: nodeId,
@@ -176,6 +179,23 @@ export class DriftLedgerService {
return { detected: toInsert.length, resolved: toResolve.length };
}
/**
* Build the spatial report for one stack and reconcile it into the ledger.
* Used by the deploy and update success hooks (and the rollback route, which
* re-deploys through deployStack) so a change resolves the findings it fixed and
* records what it left behind. Best-effort: a build or reconcile failure is
* logged and swallowed so it never fails the deploy that triggered it.
*/
async reconcileStack(nodeId: number, stackName: string): Promise<DriftReconcileResult> {
try {
const report = await buildStackDriftReport(nodeId, stackName);
return this.reconcile(nodeId, stackName, report);
} catch (error) {
console.error('[DriftLedger] reconcileStack failed for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { detected: 0, resolved: 0 };
}
}
/**
* Write a drift transition to the stack activity timeline. History-only (no
* external channel dispatch): a drift signal belongs in the activity feed, not