feat(blueprints): require confirmed rollout preview before reconcile (#1649)

* feat(blueprints): require confirmed rollout preview before reconcile

Persist place/remove approval with an intent fingerprint and transition matrix so Apply, Retry, ticks, and pin cannot mutate the fleet until the operator confirms the current blast radius. Preview surfaces requirements, health, and informational in-flight rows without executing them.

* fix(blueprints): silence unused retry nodeId lint error

* test(blueprints): harden approval gate coverage and preview clarity

Add real reconcileOne place/remove fan-out and STALE_GUARD regressions, surface reachability in the rollout dialog, align warning totals, and document the fail-closed upgrade pause.

* test(blueprints): cover legacy approval schema migration

Seed a pre-approval database with an enabled Blueprint and live deployment, run production DatabaseService startup, and assert pending null auth columns plus a fail-closed reconcile gate.

* test(blueprints): clarify legacy approval migration fixture

Extract seed/boot helpers so the migration regression reads as a linear upgrade path without changing assertions.

* fix(blueprints): report apply outcomes and gate manual withdraw

Return per-node reconcile outcomes from Confirm Apply, block create preview on unmanaged same-name stacks, and require an approved remove outcome for every manual withdraw or evict.

* fix(blueprints): scope withdraw approval to destructive eviction

Require remove approval only for snapshot/evict confirms and evict_blocked rows. Keep plain stateless standard withdraw as an immediate stop, and update withdraw-route tests to seed remove approval when needed.
This commit is contained in:
Anso
2026-07-19 15:30:26 -04:00
committed by GitHub
parent 972f2b9483
commit d94e586af3
20 changed files with 3129 additions and 152 deletions
+349 -57
View File
@@ -4,15 +4,96 @@ import {
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintService } from './BlueprintService';
import { BlueprintService, type DeployOutcome } from './BlueprintService';
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
import { NodeLabelService } from './NodeLabelService';
import { NotificationService } from './NotificationService';
import { sanitizeForLog } from '../utils/safeLog';
import {
type ConfirmableActionRef,
type PreviewAction,
filterAuthorizedExecutorActions,
intentFingerprint,
parseApprovedBlastJson,
} from './blueprintApproval';
import {
applyClearReversedEvict,
applyClearStaleGuard,
buildBlueprintPreview,
} from './blueprintPreviewProjection';
const RECONCILER_INTERVAL_MS = 60_000;
const RECONCILER_INITIAL_DELAY_MS = 5_000;
export type ConfirmedActionOutcomeStatus = 'ok' | 'failed' | 'name_conflict' | 'pending' | 'skipped';
export interface ConfirmedActionOutcome {
nodeId: number;
nodeName: string;
action: PreviewAction;
status: ConfirmedActionOutcomeStatus;
error?: string | null;
}
export interface ConfirmedPlanResult {
outcomes: ConfirmedActionOutcome[];
}
export interface ConfirmedOutcomeSummary {
total: number;
ok: number;
failed: number;
pending: number;
skipped: number;
}
export function summarizeConfirmedOutcomes(outcomes: ConfirmedActionOutcome[]): ConfirmedOutcomeSummary {
let ok = 0;
let failed = 0;
let pending = 0;
let skipped = 0;
for (const outcome of outcomes) {
switch (outcome.status) {
case 'ok':
ok += 1;
break;
case 'failed':
case 'name_conflict':
failed += 1;
break;
case 'pending':
pending += 1;
break;
case 'skipped':
skipped += 1;
break;
}
}
return { total: outcomes.length, ok, failed, pending, skipped };
}
export function messageForConfirmedOutcomes(summary: ConfirmedOutcomeSummary): string {
if (summary.failed > 0) return 'Rollout confirmed with node failures';
if (summary.pending > 0) return 'Rollout confirmed; some actions are still in progress';
return 'Rollout confirmed';
}
function mapDeployOutcome(
base: { nodeId: number; nodeName: string; action: PreviewAction },
result: DeployOutcome,
): ConfirmedActionOutcome {
if (result.status === 'active' || result.status === 'withdrawn') {
return { ...base, status: 'ok' };
}
if (result.status === 'name_conflict') {
return { ...base, status: 'name_conflict', error: result.error ?? 'name_conflict' };
}
if (result.status === 'pending' || result.status === 'deploying' || result.status === 'withdrawing') {
return { ...base, status: 'pending', error: result.error ?? null };
}
return { ...base, status: 'failed', error: result.error ?? result.status };
}
function isDeveloperModeEnabled(): boolean {
try {
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
@@ -91,9 +172,9 @@ export class BlueprintReconciler {
}
/**
* Force reconciliation for a single blueprint. Invoked by the /apply
* endpoint so users get immediate action without waiting for the
* interval.
* Force reconciliation for a single blueprint. Invoked after Confirm
* persists approval, or by pin (still gated). Prefer reconcileConfirmedPlan
* for Apply so execution uses the validated immutable action set.
*/
async reconcileOne(blueprintId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
@@ -103,6 +184,31 @@ export class BlueprintReconciler {
await this.reconcileBlueprint(blueprint, nodes);
}
/**
* Execute only the provided executor actions for an already-validated plan.
* Does not recompute or widen the action set. Returns per-node outcomes so
* Apply can report partial failure without claiming a clean rollout.
*/
async reconcileConfirmedPlan(
blueprintId: number,
executorActions: ConfirmableActionRef[],
): Promise<ConfirmedPlanResult> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint || !blueprint.enabled) {
return { outcomes: [] };
}
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (!parsed.ok || blueprint.approval_status !== 'approved') {
diagnosticLog('reconcileConfirmedPlan skipped: approval missing or invalid', { blueprintId });
return { outcomes: [] };
}
const authorized = filterAuthorizedExecutorActions(parsed.entries, executorActions);
const nodes = DatabaseService.getInstance().getNodes();
const byId = new Map(nodes.map(n => [n.id, n]));
const outcomes = await this.executeAuthorizedActions(blueprint, byId, authorized);
return { outcomes };
}
private async evaluate(): Promise<void> {
if (this.running) return; // prevent overlap on slow ticks
this.running = true;
@@ -128,69 +234,236 @@ export class BlueprintReconciler {
}
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
const decision = this.computeDecision(blueprint, allNodes);
diagnosticLog('decision computed', {
const preview = await buildBlueprintPreview(blueprint.id);
if (!preview) return;
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (
blueprint.approval_status !== 'approved'
|| !parsed.ok
|| blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint)
) {
diagnosticLog('reconcile skipped: no valid approval', {
blueprintId: blueprint.id,
effectiveApproval: preview.effectiveApproval,
});
return;
}
const authorized = filterAuthorizedExecutorActions(parsed.entries, preview.executorActions);
if (authorized.length === 0) {
diagnosticLog('reconcile skipped: no authorized executor actions', { blueprintId: blueprint.id });
return;
}
diagnosticLog('decision authorized', {
blueprintId: blueprint.id,
blueprintName: blueprint.name,
revision: blueprint.revision,
deploy: decision.deploy.length,
withdraw: decision.withdraw.length,
check: decision.check.length,
stateReview: decision.stateReview.length,
evictBlocked: decision.evictBlocked.length,
authorized: authorized.length,
unauthorized: preview.unauthorizedActions.length,
});
// 1. State-review guard for stateful blueprints reaching new nodes.
for (const node of decision.stateReview) {
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: existing
? 'Stateful blueprint revision change awaits operator confirmation'
: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
}
const byId = new Map(allNodes.map(n => [n.id, n]));
await this.executeAuthorizedActions(blueprint, byId, authorized);
}
// 2. Eviction guard for stateful blueprints leaving the selector.
for (const node of decision.evictBlocked) {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
}
// 3. Deploy missing/stale entries (stateless or pre-accepted stateful).
private async executeAuthorizedActions(
blueprint: Blueprint,
byId: Map<number, Node>,
actions: ConfirmableActionRef[],
): Promise<ConfirmedActionOutcome[]> {
const svc = BlueprintService.getInstance();
for (const node of decision.deploy) {
await svc.deployToNode(blueprint, node);
const outcomes: ConfirmedActionOutcome[] = [];
for (const { nodeId, action } of actions) {
const node = byId.get(nodeId);
if (!node) {
console.warn(
`[BlueprintReconciler] confirmed plan skipped missing node ${nodeId} for blueprint ${blueprint.id}`,
);
outcomes.push({
nodeId,
nodeName: `node-${nodeId}`,
action,
status: 'skipped',
error: 'Node not found',
});
continue;
}
outcomes.push(await this.executeOneAction(blueprint, node, action, svc));
}
return outcomes;
}
private async executeOneAction(
blueprint: Blueprint,
node: Node,
action: PreviewAction,
svc: BlueprintService,
): Promise<ConfirmedActionOutcome> {
const base = { nodeId: node.id, nodeName: node.name, action };
switch (action) {
case 'await_state_review': {
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: existing
? 'Stateful blueprint revision change awaits operator confirmation'
: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
return { ...base, status: 'ok' };
}
case 'await_evict_confirm': {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
return { ...base, status: 'ok' };
}
case 'clear_reversed_evict':
applyClearReversedEvict(blueprint.id, node.id);
return { ...base, status: 'ok' };
case 'clear_stale_guard':
applyClearStaleGuard(blueprint.id, node.id);
return { ...base, status: 'ok' };
case 'create':
case 'update': {
const result = await svc.deployToNode(blueprint, node);
return mapDeployOutcome(base, result);
}
case 'remove': {
const result = await svc.withdrawFromNode(blueprint, node);
return mapDeployOutcome(base, result);
}
case 'check_observe':
case 'check_enforce': {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) return { ...base, status: 'ok' };
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
// observe/suggest/enforce: notify path via handleDrift still respects drift_mode
await this.handleDrift(blueprint, node, reason);
return { ...base, status: 'ok' };
}
default:
return { ...base, status: 'skipped', error: `Unsupported action ${action}` };
}
}
/**
* Validate Accept against current approval and placement.
* Returns ok when the guard row and approved place outcome still match.
*/
validateGuardConfirmation(
blueprintId: number,
nodeId: number,
kind: 'accept' | 'evict',
): { ok: true } | { ok: false; code: string; error: string } {
if (kind === 'evict') {
// Guard-path Evict still requires the evict_blocked row; open withdraw uses
// validateWithdrawConfirmation without that flag.
return this.validateWithdrawConfirmation(blueprintId, nodeId, { requireEvictBlocked: true });
}
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return { ok: false, code: 'not_found', error: 'Blueprint not found' };
const node = db.getNode(nodeId);
if (!node) return { ok: false, code: 'not_found', error: 'Node not found' };
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'pending_state_review') {
return { ok: false, code: 'STALE_GUARD', error: 'Deployment is not awaiting state review' };
}
// 4. Withdraw stateless deployments leaving the selector.
for (const node of decision.withdraw) {
await svc.withdrawFromNode(blueprint, node);
const approval = this.requireApprovedRemoveOrPlace(blueprint, nodeId, 'place');
if (!approval.ok) return approval;
const desired = this.listDesiredNodes(blueprint, db.getNodes()).some(n => n.id === nodeId);
if (!desired) {
return { ok: false, code: 'STALE_GUARD', error: 'Node is no longer an approved placement target' };
}
return { ok: true };
}
/**
* Validate manual withdraw/evict against current remove approval.
* Manual destroy of an active row still requires an approved remove outcome
* and a node that is no longer desired (same contract as reconciler remove).
*/
validateWithdrawConfirmation(
blueprintId: number,
nodeId: number,
opts: { requireEvictBlocked?: boolean } = {},
): { ok: true } | { ok: false; code: string; error: string } {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return { ok: false, code: 'not_found', error: 'Blueprint not found' };
const node = db.getNode(nodeId);
if (!node) return { ok: false, code: 'not_found', error: 'Node not found' };
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status === 'withdrawn') {
return { ok: false, code: 'STALE_GUARD', error: 'No withdrawable deployment on this node' };
}
if (opts.requireEvictBlocked && dep.status !== 'evict_blocked') {
return { ok: false, code: 'STALE_GUARD', error: 'Deployment is not awaiting eviction confirmation' };
}
// 5. Drift check for active deployments.
for (const node of decision.check) {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) continue;
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
await this.handleDrift(blueprint, node, reason);
const approval = this.requireApprovedRemoveOrPlace(blueprint, nodeId, 'remove');
if (!approval.ok) return approval;
const desired = this.listDesiredNodes(blueprint, db.getNodes()).some(n => n.id === nodeId);
if (desired) {
return { ok: false, code: 'STALE_GUARD', error: 'Node is no longer an approved removal target' };
}
return { ok: true };
}
private requireApprovedRemoveOrPlace(
blueprint: Blueprint,
nodeId: number,
outcomeNeeded: 'place' | 'remove',
): { ok: true } | { ok: false; code: string; error: string } {
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (
blueprint.approval_status !== 'approved'
|| !parsed.ok
|| blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint)
) {
return { ok: false, code: 'STALE_GUARD', error: 'Blueprint approval is no longer valid; preview and confirm again' };
}
const outcome = parsed.entries.find(e => e.nodeId === nodeId)?.outcome;
if (outcome !== outcomeNeeded) {
const errors: Record<'place' | 'remove', string> = {
place: 'Node is no longer an approved placement target',
remove: 'Node is no longer an approved removal target',
};
return { ok: false, code: 'STALE_GUARD', error: errors[outcomeNeeded] };
}
return { ok: true };
}
/** Public wrapper for preview/approval projection (read-only). */
computeDecisionForPreview(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
return this.computeDecision(blueprint, allNodes);
}
/** Desired node set after pin/selector (read-only). */
listDesiredNodes(blueprint: Blueprint, allNodes: Node[]): Node[] {
if (blueprint.pinned_node_id !== null) {
const pinned = allNodes.find(n => n.id === blueprint.pinned_node_id);
return pinned ? [pinned] : [];
}
return NodeLabelService.getInstance().matchSelector(blueprint.selector, allNodes);
}
private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
@@ -244,8 +517,16 @@ export class BlueprintReconciler {
}
continue;
}
// Operator-blocking states must not be auto-acted on
if (dep.status === 'pending_state_review' || dep.status === 'evict_blocked' || dep.status === 'name_conflict') {
// In-flight and operator-blocking states: projection emits informational
// or await_* rows. Never queue effectful deploy/withdraw while in flight.
if (
dep.status === 'deploying'
|| dep.status === 'correcting'
|| dep.status === 'withdrawing'
|| dep.status === 'pending_state_review'
|| dep.status === 'evict_blocked'
|| dep.status === 'name_conflict'
) {
continue;
}
if (dep.status === 'active' && dep.applied_revision === blueprint.revision) {
@@ -270,6 +551,17 @@ export class BlueprintReconciler {
for (const dep of existingDeployments) {
if (desiredIds.has(dep.node_id)) continue;
if (dep.status === 'withdrawn') continue;
// Projection owns informational in-flight rows and clear_stale_guard
// (never-deployed pending_state_review). Do not queue withdraw/evict.
if (
dep.status === 'deploying'
|| dep.status === 'correcting'
|| dep.status === 'withdrawing'
|| dep.status === 'name_conflict'
|| (dep.status === 'pending_state_review' && dep.last_deployed_at == null)
) {
continue;
}
const node = allNodes.find(n => n.id === dep.node_id);
if (!node) continue;
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
+95 -2
View File
@@ -539,6 +539,11 @@ export interface Blueprint {
updated_at: number;
created_by: string | null;
pinned_node_id: number | null;
approval_status: 'pending' | 'approved';
approved_intent_fingerprint: string | null;
approved_blast_json: string | null;
approved_at: number | null;
approved_by: string | null;
}
export interface BlueprintDeployment {
@@ -1001,6 +1006,7 @@ export class DatabaseService {
this.migrateAddNodeLastContact();
this.migrateAddNodeCordonFields();
this.migrateAddBlueprintPinnedNode();
this.migrateAddBlueprintApproval();
this.migrateAutoHealNodeId();
this.migrateFleetSyncStickyError();
this.migrateStackDossierHashes();
@@ -2353,6 +2359,27 @@ export class DatabaseService {
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
}
private migrateAddBlueprintApproval(): void {
this.tryAddColumn('blueprints', 'approval_status', "TEXT NOT NULL DEFAULT 'pending'");
this.tryAddColumn('blueprints', 'approved_intent_fingerprint', 'TEXT');
this.tryAddColumn('blueprints', 'approved_blast_json', 'TEXT');
this.tryAddColumn('blueprints', 'approved_at', 'INTEGER');
this.tryAddColumn('blueprints', 'approved_by', 'TEXT');
// Fail-closed backfill: any pre-existing approved-looking default stays pending.
try {
this.db.prepare(
`UPDATE blueprints SET approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE approval_status IS NULL OR approval_status NOT IN ('pending', 'approved')`,
).run();
} catch (e) {
console.warn('[DatabaseService] blueprint approval backfill:', (e as Error).message);
}
}
private migrateFleetSyncStickyError(): void {
this.tryAddColumn('fleet_sync_status', 'sticky_error_code', 'TEXT');
this.tryAddColumn('fleet_sync_status', 'sticky_error_expected', 'TEXT');
@@ -3897,11 +3924,60 @@ export class DatabaseService {
}
public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined {
this.db.prepare('UPDATE blueprints SET pinned_node_id = ?, updated_at = ? WHERE id = ?')
.run(nodeId, Date.now(), blueprintId);
this.db.prepare(
`UPDATE blueprints SET pinned_node_id = ?, updated_at = ?,
approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE id = ?`,
).run(nodeId, Date.now(), blueprintId);
return this.getBlueprint(blueprintId);
}
/** Persist approval without bumping operational updated_at. */
public setBlueprintApproval(
blueprintId: number,
input: {
intentFingerprint: string;
blastJson: string;
approvedBy: string | null;
},
): Blueprint | undefined {
this.db.prepare(
`UPDATE blueprints SET
approval_status = 'approved',
approved_intent_fingerprint = ?,
approved_blast_json = ?,
approved_at = ?,
approved_by = ?
WHERE id = ?`,
).run(input.intentFingerprint, input.blastJson, Date.now(), input.approvedBy, blueprintId);
return this.getBlueprint(blueprintId);
}
public clearBlueprintApproval(blueprintId: number): void {
this.db.prepare(
`UPDATE blueprints SET
approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE id = ?`,
).run(blueprintId);
}
/** True when any deployment row is not withdrawn (blocks rename). */
public hasNonWithdrawnBlueprintDeployments(blueprintId: number): boolean {
const row = this.db.prepare(
`SELECT 1 AS ok FROM blueprint_deployments
WHERE blueprint_id = ? AND status != 'withdrawn' LIMIT 1`,
).get(blueprintId) as { ok: number } | undefined;
return !!row;
}
public updateNodeLastContact(nodeId: number): void {
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
.run(Math.floor(Date.now() / 1000), nodeId);
@@ -7031,6 +7107,11 @@ export class DatabaseService {
updated_at: row.updated_at as number,
created_by: (row.created_by as string | null) ?? null,
pinned_node_id: (row.pinned_node_id as number | null) ?? null,
approval_status: (row.approval_status as 'pending' | 'approved' | undefined) === 'approved' ? 'approved' : 'pending',
approved_intent_fingerprint: (row.approved_intent_fingerprint as string | null) ?? null,
approved_blast_json: (row.approved_blast_json as string | null) ?? null,
approved_at: (row.approved_at as number | null) ?? null,
approved_by: (row.approved_by as string | null) ?? null,
};
}
@@ -7113,6 +7194,18 @@ export class DatabaseService {
if (updates.classification_reasons !== undefined) { fields.push('classification_reasons = ?'); values.push(JSON.stringify(updates.classification_reasons)); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.bumpRevision) { fields.push('revision = revision + 1'); }
const invalidatesApproval = updates.name !== undefined
|| updates.compose_content !== undefined
|| updates.selector !== undefined
|| updates.drift_mode !== undefined
|| updates.enabled !== undefined;
if (invalidatesApproval) {
fields.push("approval_status = 'pending'");
fields.push('approved_intent_fingerprint = NULL');
fields.push('approved_blast_json = NULL');
fields.push('approved_at = NULL');
fields.push('approved_by = NULL');
}
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
+300
View File
@@ -0,0 +1,300 @@
/**
* Blueprint rollout approval: intent fingerprint, blast JSON parse,
* place/remove transition matrix, and effective approval evaluation.
*/
import { createHash } from 'crypto';
import type { Blueprint, BlueprintSelector, DriftMode } from './DatabaseService';
export type ApprovalOutcome = 'place' | 'remove';
export type EffectiveApproval = 'pending' | 'approved' | 'reapproval_required';
export type PreviewAction =
| 'create'
| 'update'
| 'remove'
| 'check_observe'
| 'check_enforce'
| 'await_state_review'
| 'await_evict_confirm'
| 'clear_stale_guard'
| 'clear_reversed_evict'
| 'in_flight_deploy'
| 'in_flight_correct'
| 'in_flight_withdraw'
| 'skip_cordoned'
| 'blocked_name_conflict';
export type ActionKind = 'executor' | 'informational';
export interface ApprovedNodeOutcome {
nodeId: number;
outcome: ApprovalOutcome;
}
export interface ConfirmableActionRef {
nodeId: number;
action: PreviewAction;
}
const PLACE_EXECUTOR = new Set<PreviewAction>([
'create',
'update',
'check_observe',
'check_enforce',
'await_state_review',
'clear_reversed_evict',
]);
const REMOVE_EXECUTOR = new Set<PreviewAction>([
'remove',
'await_evict_confirm',
'clear_stale_guard',
]);
const INFORMATIONAL = new Set<PreviewAction>([
'in_flight_deploy',
'in_flight_correct',
'in_flight_withdraw',
'skip_cordoned',
'blocked_name_conflict',
]);
const ALL_ACTIONS = new Set<PreviewAction>([
...PLACE_EXECUTOR,
...REMOVE_EXECUTOR,
...INFORMATIONAL,
]);
export function actionKind(action: PreviewAction): ActionKind {
return INFORMATIONAL.has(action) ? 'informational' : 'executor';
}
export function isExecutorAction(action: PreviewAction): boolean {
return !INFORMATIONAL.has(action);
}
/** Outcome derived when this confirmable action appears in a confirmed plan. */
export function outcomeForConfirmableAction(action: PreviewAction): ApprovalOutcome | null {
if (PLACE_EXECUTOR.has(action) || action === 'in_flight_deploy' || action === 'in_flight_correct') {
return 'place';
}
if (REMOVE_EXECUTOR.has(action) || action === 'in_flight_withdraw') {
return 'remove';
}
return null;
}
export function isActionAuthorized(outcome: ApprovalOutcome, action: PreviewAction): boolean {
if (INFORMATIONAL.has(action)) return false;
if (outcome === 'place') return PLACE_EXECUTOR.has(action);
return REMOVE_EXECUTOR.has(action);
}
function canonicalSelectorJson(selector: BlueprintSelector): string {
if (selector.type === 'nodes') {
const ids = [...selector.ids].sort((a, b) => a - b);
return JSON.stringify({ type: 'nodes', ids });
}
const any = [...selector.any].sort((a, b) => a.localeCompare(b));
const all = [...selector.all].sort((a, b) => a.localeCompare(b));
return JSON.stringify({ type: 'labels', any, all });
}
export function intentFingerprint(blueprint: Pick<
Blueprint,
'id' | 'name' | 'revision' | 'selector' | 'pinned_node_id' | 'enabled' | 'drift_mode' | 'compose_content'
>): string {
const composeHash = createHash('sha256').update(blueprint.compose_content, 'utf8').digest('hex');
const lines = [
`id:${blueprint.id}`,
`name:${blueprint.name}`,
`revision:${blueprint.revision}`,
`selector:${canonicalSelectorJson(blueprint.selector)}`,
`pin:${blueprint.pinned_node_id ?? 'null'}`,
`enabled:${blueprint.enabled ? '1' : '0'}`,
`drift:${blueprint.drift_mode}`,
`compose:${composeHash}`,
].sort((a, b) => a.localeCompare(b));
return createHash('sha256').update(lines.join('\n'), 'utf8').digest('hex');
}
export function serializeApprovedBlast(entries: ApprovedNodeOutcome[]): string {
const sorted = [...entries].sort((a, b) => a.nodeId - b.nodeId);
return JSON.stringify(sorted.map(e => ({ nodeId: e.nodeId, outcome: e.outcome })));
}
export type ParseBlastResult =
| { ok: true; entries: ApprovedNodeOutcome[] }
| { ok: false; reason: string };
/**
* Fail-closed parser. Rejects unknown keys, duplicates, invalid ids/outcomes,
* and non-canonical ordering. Does not mutate the database.
*/
function isPlainObject(item: unknown): item is Record<string, unknown> {
return item != null && typeof item === 'object' && !Array.isArray(item);
}
function hasExactKeys(obj: Record<string, unknown>, keyA: string, keyB: string): boolean {
const keys = Object.keys(obj);
return keys.length === 2 && keys.includes(keyA) && keys.includes(keyB);
}
function isPositiveInt(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
/**
* Fail-closed parser. Rejects unknown keys, duplicates, invalid ids/outcomes,
* and non-canonical ordering. Does not mutate the database.
*/
export function parseApprovedBlastJson(raw: string | null | undefined): ParseBlastResult {
if (raw == null || raw === '') {
return { ok: false, reason: 'missing' };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, reason: 'malformed_json' };
}
if (!Array.isArray(parsed)) {
return { ok: false, reason: 'not_array' };
}
const entries: ApprovedNodeOutcome[] = [];
const seen = new Set<number>();
for (const item of parsed) {
if (!isPlainObject(item) || !hasExactKeys(item, 'nodeId', 'outcome')) {
return { ok: false, reason: isPlainObject(item) ? 'unknown_keys' : 'bad_element' };
}
const { nodeId, outcome } = item;
if (!isPositiveInt(nodeId)) {
return { ok: false, reason: 'invalid_node_id' };
}
if (outcome !== 'place' && outcome !== 'remove') {
return { ok: false, reason: 'invalid_outcome' };
}
if (seen.has(nodeId)) {
return { ok: false, reason: 'duplicate_node' };
}
seen.add(nodeId);
entries.push({ nodeId, outcome });
}
for (let i = 1; i < entries.length; i++) {
if (entries[i].nodeId <= entries[i - 1].nodeId) {
return { ok: false, reason: 'non_canonical_order' };
}
}
return { ok: true, entries };
}
export function deriveBlastFromConfirmableActions(actions: ConfirmableActionRef[]): ApprovedNodeOutcome[] {
const byNode = new Map<number, ApprovalOutcome>();
for (const { nodeId, action } of actions) {
const outcome = outcomeForConfirmableAction(action);
if (!outcome) continue;
const existing = byNode.get(nodeId);
if (!existing) {
byNode.set(nodeId, outcome);
} else if (existing !== outcome) {
// Conflicting outcomes for one node in one confirm: last write wins is unsafe;
// prefer remove if both appear (fail closed toward requiring reapproval next).
byNode.set(nodeId, 'remove');
}
}
return [...byNode.entries()]
.map(([nodeId, outcome]) => ({ nodeId, outcome }))
.sort((a, b) => a.nodeId - b.nodeId);
}
export function confirmableActionsEqual(a: ConfirmableActionRef[], b: ConfirmableActionRef[]): boolean {
if (a.length !== b.length) return false;
const key = (x: ConfirmableActionRef) => `${x.nodeId}:${x.action}`;
const sa = a.map(key).sort();
const sb = b.map(key).sort();
return sa.every((v, i) => v === sb[i]);
}
export function parseConfirmableActionsBody(
raw: unknown,
): { ok: true; actions: ConfirmableActionRef[] } | { ok: false; reason: string } {
if (!Array.isArray(raw)) {
return { ok: false, reason: 'not_array' };
}
const actions: ConfirmableActionRef[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!isPlainObject(item) || !hasExactKeys(item, 'nodeId', 'action')) {
return { ok: false, reason: isPlainObject(item) ? 'unknown_keys' : 'bad_element' };
}
const { nodeId, action } = item;
if (!isPositiveInt(nodeId)) {
return { ok: false, reason: 'invalid_node_id' };
}
if (typeof action !== 'string' || !ALL_ACTIONS.has(action as PreviewAction)) {
return { ok: false, reason: 'invalid_action' };
}
const k = `${nodeId}:${action}`;
if (seen.has(k)) {
return { ok: false, reason: 'duplicate_pair' };
}
seen.add(k);
actions.push({ nodeId, action: action as PreviewAction });
}
return { ok: true, actions };
}
export interface BlueprintApprovalFields {
approval_status: 'pending' | 'approved';
approved_intent_fingerprint: string | null;
approved_blast_json: string | null;
approved_at: number | null;
approved_by: string | null;
}
export function evaluateEffectiveApproval(
blueprint: Blueprint & Partial<BlueprintApprovalFields>,
executorActions: ConfirmableActionRef[],
): { effectiveApproval: EffectiveApproval; unauthorizedActions: ConfirmableActionRef[]; blast: ApprovedNodeOutcome[] | null } {
const status = blueprint.approval_status ?? 'pending';
const fp = blueprint.approved_intent_fingerprint ?? null;
const rawBlast = blueprint.approved_blast_json ?? null;
const parsed = parseApprovedBlastJson(rawBlast);
const currentFp = intentFingerprint(blueprint);
if (status !== 'approved' || !fp || !parsed.ok || fp !== currentFp) {
return { effectiveApproval: 'pending', unauthorizedActions: executorActions, blast: null };
}
const byNode = new Map(parsed.entries.map(e => [e.nodeId, e.outcome]));
const unauthorized: ConfirmableActionRef[] = [];
for (const ref of executorActions) {
const outcome = byNode.get(ref.nodeId);
if (!outcome || !isActionAuthorized(outcome, ref.action)) {
unauthorized.push(ref);
}
}
if (unauthorized.length > 0) {
return {
effectiveApproval: 'reapproval_required',
unauthorizedActions: unauthorized,
blast: parsed.entries,
};
}
return { effectiveApproval: 'approved', unauthorizedActions: [], blast: parsed.entries };
}
export function filterAuthorizedExecutorActions(
blast: ApprovedNodeOutcome[],
executorActions: ConfirmableActionRef[],
): ConfirmableActionRef[] {
const byNode = new Map(blast.map(e => [e.nodeId, e.outcome]));
return executorActions.filter(ref => {
const outcome = byNode.get(ref.nodeId);
return outcome != null && isActionAuthorized(outcome, ref.action);
});
}
export type { DriftMode };
@@ -0,0 +1,645 @@
/**
* Pure Blueprint rollout preview projection. Zero DB writes.
* Executor cleanup helpers for clear_reversed_evict / clear_stale_guard live at the bottom.
*/
import { parse as parseYaml } from 'yaml';
import {
DatabaseService,
type Blueprint,
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintReconciler, type ReconcileDecision } from './BlueprintReconciler';
import { parseInterpolationRefs } from '../helpers/envVarParse';
import { normalizeEnvFileField } from '../helpers/envFileResolution';
import { isLikelySecretKey } from '../helpers/secretClassification';
import {
type PreviewAction,
type ConfirmableActionRef,
type EffectiveApproval,
type BlueprintApprovalFields,
actionKind,
isExecutorAction,
intentFingerprint,
evaluateEffectiveApproval,
} from './blueprintApproval';
export const BLUEPRINT_PREVIEW_PILOT_STALE_MS = 60_000;
export const BLUEPRINT_PREVIEW_PROXY_STALE_MS = 120_000;
export type PreviewSeverity = 'safe' | 'warning' | 'blocker';
export interface PreviewChangeRow {
nodeId: number;
nodeName: string;
nodeType: 'local' | 'remote';
mode: string | null;
status: Node['status'];
contactAt: number | null;
contactSource: 'local' | 'pilot_last_seen' | 'last_successful_contact';
action: PreviewAction;
severity: PreviewSeverity;
kind: 'executor' | 'informational';
detail: string;
reachabilityNote: string;
}
export interface PreviewRequirementVariable {
name: string;
required: boolean;
hasDefault: boolean;
alternate: boolean;
likelySecret: boolean;
}
export interface PreviewRequirementEnvFile {
path: string;
required: boolean;
}
export interface PreviewRequirements {
variables: PreviewRequirementVariable[];
envFiles: PreviewRequirementEnvFile[];
composeSecrets: Array<{ name: string }>;
}
export interface PreviewWarningItem {
id: string;
source: 'change' | 'requirement' | 'compat' | 'health';
severity: PreviewSeverity;
message: string;
}
export interface BlueprintPreviewResult {
blueprintId: number;
classification: Blueprint['classification'];
matchedNodes: Array<{ id: number; name: string; type: 'local' | 'remote' }>;
plannedDeployments: Array<{ id: number; name: string }>;
plannedDriftChecks: Array<{ id: number; name: string }>;
plannedEvictions: number[];
name: string;
revision: number;
updatedAt: number;
driftMode: Blueprint['drift_mode'];
stackName: string;
approvalStatus: 'pending' | 'approved';
effectiveApproval: EffectiveApproval;
planFingerprint: string;
generatedAt: number;
summary: { safe: number; warning: number; blocker: number; total: number };
changes: PreviewChangeRow[];
confirmableActions: ConfirmableActionRef[];
executorActions: ConfirmableActionRef[];
unauthorizedActions: ConfirmableActionRef[];
requirements: PreviewRequirements;
compatibilityWarnings: string[];
healthNote: string;
blockers: PreviewWarningItem[];
warnings: PreviewWarningItem[];
}
const HEALTH_NOTE = 'Reachability is from cached node status and mode-specific contact timestamps; preview does not probe remotes.';
function isMutatingAction(action: PreviewAction): boolean {
return action === 'create'
|| action === 'update'
|| action === 'remove'
|| action === 'check_enforce';
}
function contactInfo(node: Node): {
contactAt: number | null;
contactSource: PreviewChangeRow['contactSource'];
reachabilityNote: string;
elevateMutating: PreviewSeverity | null;
} {
if (node.type === 'local') {
return {
contactAt: null,
contactSource: 'local',
reachabilityNote: 'Local node',
elevateMutating: null,
};
}
if (node.mode === 'pilot_agent') {
const seen = node.pilot_last_seen ?? null;
const age = seen != null ? Date.now() - seen : null;
const heartbeatStale = seen == null || (age != null && age > BLUEPRINT_PREVIEW_PILOT_STALE_MS);
if (node.status === 'offline' || node.status === 'unknown') {
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: node.status === 'offline' && !heartbeatStale
? 'Pilot heartbeat fresh but cached status is offline'
: 'Pilot node cached as offline or unknown',
elevateMutating: 'blocker',
};
}
if (heartbeatStale) {
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: 'Pilot heartbeat expired (cached)',
elevateMutating: 'warning',
};
}
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: 'Pilot heartbeat fresh (cached)',
elevateMutating: null,
};
}
const sec = node.last_successful_contact ?? null;
const contactAt = sec != null ? sec * 1000 : null;
const age = contactAt != null ? Date.now() - contactAt : null;
if (node.status === 'offline' || node.status === 'unknown') {
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Remote node cached as offline or unknown',
elevateMutating: 'blocker',
};
}
if (contactAt == null || (age != null && age > BLUEPRINT_PREVIEW_PROXY_STALE_MS)) {
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Proxy contact missing or stale (cached status)',
elevateMutating: 'warning',
};
}
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Proxy contact fresh (cached)',
elevateMutating: null,
};
}
function applyHealthSeverity(
action: PreviewAction,
base: PreviewSeverity,
elevate: PreviewSeverity | null,
): PreviewSeverity {
if (!elevate) return base;
if (isMutatingAction(action)) {
if (elevate === 'blocker') return 'blocker';
return base === 'safe' ? 'warning' : base;
}
// Non-mutating actions: soft-elevate safe→warning only; never promote to blocker.
if (elevate === 'warning' && base === 'safe') return 'warning';
return base;
}
function driftCheckAction(driftMode: Blueprint['drift_mode']): PreviewAction {
return driftMode === 'enforce' ? 'check_enforce' : 'check_observe';
}
function driftCheckSeverity(action: PreviewAction): PreviewSeverity {
return action === 'check_enforce' ? 'warning' : 'safe';
}
function composeSecretName(sec: unknown): string | null {
if (typeof sec === 'string') return sec;
if (sec && typeof sec === 'object' && typeof (sec as { source?: unknown }).source === 'string') {
return (sec as { source: string }).source;
}
return null;
}
function pushUniqueSecret(composeSecrets: Array<{ name: string }>, name: string): void {
if (!composeSecrets.some(c => c.name === name)) {
composeSecrets.push({ name });
}
}
function extractRequirements(composeContent: string): {
requirements: PreviewRequirements;
compat: string[];
reqWarnings: PreviewWarningItem[];
} {
const compat: string[] = [];
const reqWarnings: PreviewWarningItem[] = [];
const variables: PreviewRequirementVariable[] = [];
const envFiles: PreviewRequirementEnvFile[] = [];
const composeSecrets: Array<{ name: string }> = [];
let parsed: unknown;
try {
parsed = parseYaml(composeContent);
} catch (err) {
compat.push(`compose YAML did not parse: ${err instanceof Error ? err.message : String(err)}`);
return {
requirements: { variables: [], envFiles: [], composeSecrets: [] },
compat,
reqWarnings,
};
}
for (const ref of parseInterpolationRefs(composeContent)) {
variables.push({
name: ref.name,
required: ref.required,
hasDefault: ref.hasDefault,
alternate: ref.alternate,
likelySecret: isLikelySecretKey(ref.name),
});
if (ref.required) {
reqWarnings.push({
id: `req:var:${ref.name}`,
source: 'requirement',
severity: 'warning',
message: `Required interpolation \${${ref.name}} must be set on target nodes`,
});
}
}
if (parsed && typeof parsed === 'object') {
const doc = parsed as Record<string, unknown>;
const services = (doc.services && typeof doc.services === 'object')
? doc.services as Record<string, unknown>
: {};
const byPath = new Map<string, boolean>();
for (const [svcName, svc] of Object.entries(services)) {
if (!svc || typeof svc !== 'object') continue;
const s = svc as Record<string, unknown>;
for (const entry of normalizeEnvFileField(s.env_file)) {
const prev = byPath.get(entry.rawPath);
if (prev === undefined) {
byPath.set(entry.rawPath, entry.required);
} else if (prev !== entry.required) {
byPath.set(entry.rawPath, true);
reqWarnings.push({
id: `req:envfile-conflict:${entry.rawPath}`,
source: 'requirement',
severity: 'warning',
message: `env_file "${entry.rawPath}" has conflicting required flags (service ${svcName})`,
});
}
}
if (Array.isArray(s.secrets)) {
for (const sec of s.secrets) {
const name = composeSecretName(sec);
if (name) pushUniqueSecret(composeSecrets, name);
}
}
}
for (const [path, required] of byPath) {
envFiles.push({ path, required });
if (required) {
reqWarnings.push({
id: `req:envfile:${path}`,
source: 'requirement',
severity: 'warning',
message: `Required env_file "${path}" must exist on target nodes`,
});
}
}
const topSecrets = doc.secrets;
if (topSecrets && typeof topSecrets === 'object') {
for (const name of Object.keys(topSecrets as Record<string, unknown>)) {
pushUniqueSecret(composeSecrets, name);
}
}
}
return { requirements: { variables, envFiles, composeSecrets }, compat, reqWarnings };
}
interface RawAction {
node: Node;
action: PreviewAction;
severity: PreviewSeverity;
detail: string;
}
function projectActions(
blueprint: Blueprint,
allNodes: Node[],
deployments: BlueprintDeployment[],
decision: ReconcileDecision,
): RawAction[] {
const byId = new Map(allNodes.map(n => [n.id, n]));
const depByNode = new Map(deployments.map(d => [d.node_id, d]));
const desiredNodes = BlueprintReconciler.getInstance().listDesiredNodes(blueprint, allNodes);
const desiredIds = new Set(desiredNodes.map(n => n.id));
const out: RawAction[] = [];
const seen = new Set<number>();
const push = (node: Node, action: PreviewAction, severity: PreviewSeverity, detail: string) => {
out.push({ node, action, severity, detail });
seen.add(node.id);
};
// Status-precedence pass: in-flight, name conflict, and clear_* must win over
// decision.withdraw / evictBlocked so Confirm never authorizes mid-flight mutates
// and never-deployed guards stay on the remove-only clear_stale path.
for (const dep of deployments) {
const node = byId.get(dep.node_id);
if (!node) continue;
const desired = desiredIds.has(dep.node_id);
const status = dep.status;
if (status === 'name_conflict') {
push(node, 'blocked_name_conflict', 'blocker', 'Name conflict; will not deploy or withdraw');
continue;
}
if (status === 'deploying') {
push(node, 'in_flight_deploy', 'warning', desired ? 'Deploy in flight' : 'Deploy in flight (leaving selector)');
continue;
}
if (status === 'correcting') {
push(node, 'in_flight_correct', 'warning', desired ? 'Drift correction in flight' : 'Correction in flight (leaving selector)');
continue;
}
if (status === 'withdrawing') {
push(node, 'in_flight_withdraw', 'warning', 'Withdraw in flight');
continue;
}
if (!desired && status === 'pending_state_review' && dep.last_deployed_at == null) {
push(node, 'clear_stale_guard', 'warning', 'Never-deployed state-review row; clear without compose down');
continue;
}
if (desired && status === 'evict_blocked') {
push(node, 'clear_reversed_evict', 'warning', 'Eviction guard while node is desired again; clear guard only');
}
}
for (const node of decision.deploy) {
if (seen.has(node.id)) continue;
const dep = depByNode.get(node.id);
if (!dep) push(node, 'create', 'safe', 'New placement');
else push(node, 'update', 'safe', 'Revision or retry deploy');
}
for (const node of decision.withdraw) {
if (seen.has(node.id)) continue;
push(node, 'remove', 'safe', 'Withdraw from selector');
}
for (const node of decision.check) {
if (seen.has(node.id)) continue;
const action = driftCheckAction(blueprint.drift_mode);
push(
node,
action,
driftCheckSeverity(action),
action === 'check_enforce' ? 'Drift check may auto-correct (enforce)' : 'Drift observe/suggest check',
);
}
for (const node of decision.stateReview) {
if (seen.has(node.id)) continue;
push(node, 'await_state_review', 'warning', 'Stateful placement awaits operator confirmation');
}
for (const node of decision.evictBlocked) {
if (seen.has(node.id)) continue;
push(node, 'await_evict_confirm', 'warning', 'Stateful eviction awaits operator confirmation');
}
for (const node of desiredNodes) {
if (seen.has(node.id)) continue;
const dep = depByNode.get(node.id);
if (!dep && node.cordoned && blueprint.pinned_node_id !== node.id) {
push(node, 'skip_cordoned', 'warning', 'Cordoned; new placements skipped');
}
}
for (const dep of deployments) {
if (seen.has(dep.node_id)) continue;
const node = byId.get(dep.node_id);
if (!node) continue;
const desired = desiredIds.has(dep.node_id);
const status = dep.status;
if (desired) {
if (status === 'pending_state_review') {
push(node, 'await_state_review', 'warning', 'Awaiting state review');
} else if (status === 'drifted') {
const action = driftCheckAction(blueprint.drift_mode);
push(node, action, driftCheckSeverity(action), 'Drifted; check pending');
} else if (status === 'failed' || status === 'pending') {
push(node, 'update', 'safe', 'Retry failed or pending deployment');
} else if (status === 'withdrawn') {
push(node, 'create', 'safe', 'Withdrawn but desired again');
} else if (status === 'active') {
const action = driftCheckAction(blueprint.drift_mode);
push(node, action, driftCheckSeverity(action), 'Active deployment check');
}
} else {
if (status === 'withdrawn') continue;
if (status === 'pending_state_review' || status === 'evict_blocked') {
push(node, 'await_evict_confirm', 'warning', 'Stateful leave awaits eviction confirmation');
} else if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
push(node, 'await_evict_confirm', 'warning', 'Stateful leave awaits confirmation');
} else {
push(node, 'remove', 'safe', 'Leave selector');
}
}
}
return out;
}
function asApprovedBlueprint(blueprint: Blueprint): Blueprint & BlueprintApprovalFields {
const b = blueprint as Blueprint & Partial<BlueprintApprovalFields>;
return {
...blueprint,
approval_status: b.approval_status ?? 'pending',
approved_intent_fingerprint: b.approved_intent_fingerprint ?? null,
approved_blast_json: b.approved_blast_json ?? null,
approved_at: b.approved_at ?? null,
approved_by: b.approved_by ?? null,
};
}
/** Upgrade create rows to blockers when an unmanaged same-name stack already exists. */
async function applyCreateNameConflictBlockers(blueprintName: string, raw: RawAction[]): Promise<void> {
const { BlueprintService } = await import('./BlueprintService');
const svc = BlueprintService.getInstance();
for (const row of raw) {
if (row.action !== 'create') continue;
if (!(await svc.hasNameConflict(blueprintName, row.node))) continue;
row.action = 'blocked_name_conflict';
row.severity = 'blocker';
row.detail = 'Unmanaged stack with this name already exists on this node';
}
}
export async function buildBlueprintPreview(blueprintId: number): Promise<BlueprintPreviewResult | null> {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const allNodes = db.getNodes();
const deployments = db.listDeployments(blueprintId);
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
const raw = projectActions(blueprint, allNodes, deployments, decision);
await applyCreateNameConflictBlockers(blueprint.name, raw);
const changes: PreviewChangeRow[] = [];
for (const row of raw) {
const health = contactInfo(row.node);
const severity = applyHealthSeverity(row.action, row.severity, health.elevateMutating);
changes.push({
nodeId: row.node.id,
nodeName: row.node.name,
nodeType: row.node.type,
mode: row.node.mode ?? null,
status: row.node.status,
contactAt: health.contactAt,
contactSource: health.contactSource,
action: row.action,
severity,
kind: actionKind(row.action),
detail: row.detail,
reachabilityNote: health.reachabilityNote,
});
}
const toActionRef = (c: { nodeId: number; action: PreviewAction }): ConfirmableActionRef => ({
nodeId: c.nodeId,
action: c.action,
});
const confirmable = changes
.filter(c => c.action !== 'skip_cordoned' && c.action !== 'blocked_name_conflict')
.map(toActionRef);
const executorActions = changes.filter(c => isExecutorAction(c.action)).map(toActionRef);
const approvedBp = asApprovedBlueprint(blueprint);
const { effectiveApproval, unauthorizedActions } = evaluateEffectiveApproval(approvedBp, executorActions);
const { requirements, compat, reqWarnings } = extractRequirements(blueprint.compose_content);
const compatibilityWarnings = [...blueprint.classification_reasons, ...compat];
const blockers: PreviewWarningItem[] = [];
const warnings: PreviewWarningItem[] = [];
let safeCount = 0;
for (const c of changes) {
if (c.severity === 'safe') {
safeCount += 1;
continue;
}
let message = `${c.nodeName}: ${c.detail}`;
if (c.reachabilityNote !== 'Local node') {
message += ` [${c.nodeType}/${c.status}: ${c.reachabilityNote}]`;
}
const item: PreviewWarningItem = {
id: `change:${c.nodeId}:${c.action}`,
source: 'change',
severity: c.severity,
message,
};
if (c.severity === 'blocker') blockers.push(item);
else warnings.push(item);
}
warnings.push(...reqWarnings);
for (const msg of compatibilityWarnings) {
warnings.push({ id: `compat:${createHashId(msg)}`, source: 'compat', severity: 'warning', message: msg });
}
// Totals include requirement and compatibility warnings so UI header counts
// match the lists operators see (not only per-change severity).
const summary = {
safe: safeCount,
warning: warnings.length,
blocker: blockers.length,
total: changes.length,
};
const desiredNodes = BlueprintReconciler.getInstance().listDesiredNodes(blueprint, allNodes);
const desiredIds = new Set(desiredNodes.map(n => n.id));
const willDeploy = desiredNodes.filter(n => !deployments.some(d => d.node_id === n.id));
const willCheck = desiredNodes.filter(n => deployments.some(d => d.node_id === n.id && d.status === 'active'));
const willEvict = deployments
.filter(d => !desiredIds.has(d.node_id) && d.status !== 'withdrawn')
.map(d => d.node_id);
return {
blueprintId: blueprint.id,
classification: blueprint.classification,
matchedNodes: desiredNodes.map(n => ({ id: n.id, name: n.name, type: n.type })),
plannedDeployments: willDeploy.map(n => ({ id: n.id, name: n.name })),
plannedDriftChecks: willCheck.map(n => ({ id: n.id, name: n.name })),
plannedEvictions: willEvict,
name: blueprint.name,
revision: blueprint.revision,
updatedAt: blueprint.updated_at,
driftMode: blueprint.drift_mode,
stackName: blueprint.name,
approvalStatus: approvedBp.approval_status,
effectiveApproval,
planFingerprint: intentFingerprint(blueprint),
generatedAt: Date.now(),
summary,
changes,
confirmableActions: confirmable,
executorActions,
unauthorizedActions,
requirements,
compatibilityWarnings,
healthNote: HEALTH_NOTE,
blockers,
warnings,
};
}
function createHashId(msg: string): string {
let h = 0;
for (let i = 0; i < msg.length; i++) h = ((h << 5) - h + msg.charCodeAt(i)) | 0;
return String(h);
}
/** Lightweight list/detail evaluator: no requirements/health fanout. */
export function evaluateLightweightEffectiveApproval(blueprintId: number): {
effectiveApproval: EffectiveApproval;
unauthorizedActions: ConfirmableActionRef[];
} | null {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const allNodes = db.getNodes();
const deployments = db.listDeployments(blueprintId);
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
const raw = projectActions(blueprint, allNodes, deployments, decision);
const executorActions = raw
.filter(r => isExecutorAction(r.action))
.map(r => ({ nodeId: r.node.id, action: r.action }));
const { effectiveApproval, unauthorizedActions } = evaluateEffectiveApproval(
asApprovedBlueprint(blueprint),
executorActions,
);
return { effectiveApproval, unauthorizedActions };
}
/** Executor: clear reversed eviction guard. Cleanup-only this pass. */
export function applyClearReversedEvict(blueprintId: number, nodeId: number): void {
const db = DatabaseService.getInstance();
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'evict_blocked') return;
if (dep.last_deployed_at == null) {
db.deleteDeployment(blueprintId, nodeId);
return;
}
db.upsertDeployment({
blueprint_id: blueprintId,
node_id: nodeId,
status: 'active',
applied_revision: dep.applied_revision,
last_deployed_at: dep.last_deployed_at,
last_checked_at: dep.last_checked_at,
last_drift_at: dep.last_drift_at,
drift_summary: null,
last_error: null,
});
}
/** Executor: delete never-deployed stale guard row. */
export function applyClearStaleGuard(blueprintId: number, nodeId: number): void {
const db = DatabaseService.getInstance();
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'pending_state_review' || dep.last_deployed_at != null) return;
db.deleteDeployment(blueprintId, nodeId);
}