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
@@ -118,6 +118,7 @@ vi.mock('../services/MeshService', () => ({
}));
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import { DriftLedgerService } from '../services/DriftLedgerService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
@@ -620,6 +621,48 @@ describe('ComposeService - updateStack prune-on-update', () => {
// ── withRegistryAuth ───────────────────────────────────────────────────
describe('ComposeService - drift reconcile hook', () => {
it('reconciles the drift ledger after a successful update', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
mockGetGlobalSettings.mockReturnValue({});
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
const promise = ComposeService.getInstance(1).updateStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
spy.mockRestore();
});
it('reconciles the drift ledger after a successful deploy', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
mockGetGlobalSettings.mockReturnValue({});
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
const promise = ComposeService.getInstance(1).deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
spy.mockRestore();
});
it('does not reconcile the ledger when a deploy fails', async () => {
setupAutoCloseSpawn(1); // non-zero exit => the deploy rejects before the post-success hook
mockListContainers.mockResolvedValue([]);
mockGetGlobalSettings.mockReturnValue({});
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
const result = await ComposeService.getInstance(1).deployStack('my-stack').then(() => null, (e: Error) => e);
expect(result).toBeInstanceOf(Error);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
describe('ComposeService - withRegistryAuth', () => {
it('passes default env when no registries configured', async () => {
mockGetRegistries.mockReturnValue([]);