fix: reconcile sticky update indicators with Anatomy preview (#1698)

* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
+45 -1
View File
@@ -17,7 +17,9 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
import DockerController, { type BulkStackInfo } from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
import { UpdatePreviewService } from '../services/UpdatePreviewService';
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
@@ -2243,6 +2245,8 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
// GET consumers never mutate persisted scanner state.
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
res.json(preview);
} catch (error) {
@@ -2251,6 +2255,45 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
}
});
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Snapshot write-generation watermarks before the read-only preview so a
// later clear can erase older confirmed/sticky rows without racing a
// scanner that reserved or rewrote the row after this observation.
const imageUpdates = ImageUpdateService.getInstance();
const db = DatabaseService.getInstance();
const observedMemoryGeneration = imageUpdates.peekStackWriteGeneration(req.nodeId, stackName);
const observedRowGeneration = db.getStackUpdateWriteGeneration(req.nodeId, stackName);
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
let reconciled = false;
if (isAuthoritativeNegativePreview(preview)) {
const clearResult = await imageUpdates.commitPreviewClear(
req.nodeId,
stackName,
observedMemoryGeneration,
observedRowGeneration,
);
if (clearResult === 'cleared') {
reconciled = true;
invalidateFleetUpdateCache();
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: req.nodeId,
stackName,
action: 'update-status-reconciled',
ts: Date.now(),
});
}
}
res.json({ ...preview, reconciled });
} catch (error) {
console.error('[Stacks] Update preview reconcile failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute update preview' });
}
});
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
@@ -2271,6 +2314,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateFleetUpdateCache();
invalidateNodeCaches(req.nodeId);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',