diff --git a/src/resources/extensions/sf/auto/phases.ts b/src/resources/extensions/sf/auto/phases.ts index 41398ebf7..d002181cc 100644 --- a/src/resources/extensions/sf/auto/phases.ts +++ b/src/resources/extensions/sf/auto/phases.ts @@ -45,6 +45,10 @@ import { MergeConflictError } from "../git-service.js"; import { recordLearnedOutcome } from "../learning/runtime.js"; import { sfRoot } from "../paths.js"; import { resolvePersistModelChanges } from "../preferences.js"; +import { + ensureProductionMutationApprovalTemplate, + readProductionMutationApprovalStatus, +} from "../production-mutation-approval.js"; import { resetEvidence } from "../safety/evidence-collector.js"; import { cleanupCheckpoint, @@ -1357,19 +1361,46 @@ export async function runGuards( ...task.expected_output, ].join("\n"); if (requiresHumanProductionMutationApproval(taskText)) { - const msg = - `Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` + - "Pause for an explicit safe server/VM target, cleanup/rollback path, and operator approval."; - ctx.ui.notify(msg, "error"); - deps.sendDesktopNotification( - "SF", - "Production mutation guard paused auto-mode", - "warning", - "safety", - basename(s.originalBasePath || s.basePath), + const approvalUnit = { + milestoneId: activeMilestone.id, + sliceId: activeSlice.id, + taskId: activeTask.id, + taskTitle: task.title, + taskText, + }; + const approvalBasePath = s.originalBasePath || s.basePath; + const approval = readProductionMutationApprovalStatus( + approvalBasePath, + approvalUnit, ); - await deps.pauseAuto(ctx, pi); - return { action: "break", reason: "production-mutation-guard" }; + if (approval.approved) { + ctx.ui.notify( + `Production mutation approval accepted for ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${approval.path}`, + "warning", + ); + } else { + const template = ensureProductionMutationApprovalTemplate( + approvalBasePath, + approvalUnit, + ); + const reasons = approval.reasons.length + ? ` Missing/invalid fields: ${approval.reasons.join("; ")}.` + : ""; + const msg = + `Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` + + `${template.created ? "Created" : "Reusing"} approval gate at ${template.path}. ` + + `Fill it with an explicit safe server/VM target, cleanup/rollback path, and operator approval, then rerun sf headless auto.${reasons}`; + ctx.ui.notify(msg, "error"); + deps.sendDesktopNotification( + "SF", + "Production mutation guard paused auto-mode", + "warning", + "safety", + basename(s.originalBasePath || s.basePath), + ); + await deps.pauseAuto(ctx, pi); + return { action: "break", reason: "production-mutation-guard" }; + } } } } diff --git a/src/resources/extensions/sf/production-mutation-approval.ts b/src/resources/extensions/sf/production-mutation-approval.ts new file mode 100644 index 000000000..d2e90b42d --- /dev/null +++ b/src/resources/extensions/sf/production-mutation-approval.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; +import { sfRoot } from "./paths.js"; + +export const PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION = 1; + +export interface ProductionMutationUnit { + milestoneId: string; + sliceId: string; + taskId: string; + taskTitle: string; + taskText: string; +} + +export interface ProductionMutationApproval { + schemaVersion: typeof PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION; + unitId: string; + status: "pending" | "approved"; + risk: "production-unified-failover-post"; + task: { + title: string; + text: string; + }; + approval: { + approved: boolean; + approvedBy: string; + approvedAt: string; + safeServerId: string; + safeVmNames: string[]; + cleanupPlan: string; + rollbackPlan: string; + notes: string; + }; + instructions: string[]; +} + +export interface ProductionMutationApprovalStatus { + path: string; + approved: boolean; + reasons: string[]; +} + +function unitId(unit: ProductionMutationUnit): string { + return `${unit.milestoneId}/${unit.sliceId}/${unit.taskId}`; +} + +function safePathSegment(value: string): string { + return value.replace(/[^A-Za-z0-9_.-]/g, "_"); +} + +export function productionMutationApprovalPath( + basePath: string, + unit: Pick, +): string { + const fileName = [ + safePathSegment(unit.milestoneId), + safePathSegment(unit.sliceId), + safePathSegment(unit.taskId), + ].join("-"); + return join( + sfRoot(basePath), + "approvals", + "production-mutations", + `${fileName}.json`, + ); +} + +export function buildProductionMutationApprovalTemplate( + unit: ProductionMutationUnit, +): ProductionMutationApproval { + return { + schemaVersion: PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION, + unitId: unitId(unit), + status: "pending", + risk: "production-unified-failover-post", + task: { + title: unit.taskTitle, + text: unit.taskText, + }, + approval: { + approved: false, + approvedBy: "", + approvedAt: "", + safeServerId: "", + safeVmNames: [], + cleanupPlan: "", + rollbackPlan: "", + notes: "", + }, + instructions: [ + "Set status to approved and approval.approved to true only after selecting a safe non-customer-impacting target.", + "Fill approvedBy, approvedAt, safeServerId, safeVmNames, cleanupPlan, and rollbackPlan.", + "Then rerun sf headless auto.", + ], + }; +} + +export function ensureProductionMutationApprovalTemplate( + basePath: string, + unit: ProductionMutationUnit, +): { path: string; created: boolean } { + const path = productionMutationApprovalPath(basePath, unit); + if (existsSync(path)) return { path, created: false }; + atomicWriteSync( + path, + JSON.stringify(buildProductionMutationApprovalTemplate(unit), null, 2) + + "\n", + ); + return { path, created: true }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function nonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => nonEmptyString(item)) + ); +} + +export function validateProductionMutationApproval( + data: unknown, + unit: ProductionMutationUnit, +): { approved: boolean; reasons: string[] } { + const reasons: string[] = []; + if (!isRecord(data)) { + return { approved: false, reasons: ["approval file is not a JSON object"] }; + } + + if (data.schemaVersion !== PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION) { + reasons.push( + `schemaVersion must be ${PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION}`, + ); + } + if (data.unitId !== unitId(unit)) { + reasons.push(`unitId must be ${unitId(unit)}`); + } + if (data.status !== "approved") { + reasons.push("status must be approved"); + } + if (data.risk !== "production-unified-failover-post") { + reasons.push("risk must be production-unified-failover-post"); + } + + const approval = data.approval; + if (!isRecord(approval)) { + reasons.push("approval must be an object"); + return { approved: false, reasons }; + } + if (approval.approved !== true) { + reasons.push("approval.approved must be true"); + } + for (const field of [ + "approvedBy", + "approvedAt", + "safeServerId", + "cleanupPlan", + "rollbackPlan", + ] as const) { + if (!nonEmptyString(approval[field])) { + reasons.push(`approval.${field} is required`); + } + } + if (!nonEmptyStringArray(approval.safeVmNames)) { + reasons.push("approval.safeVmNames must contain at least one VM name"); + } + + return { approved: reasons.length === 0, reasons }; +} + +export function readProductionMutationApprovalStatus( + basePath: string, + unit: ProductionMutationUnit, +): ProductionMutationApprovalStatus { + const path = productionMutationApprovalPath(basePath, unit); + if (!existsSync(path)) { + return { path, approved: false, reasons: ["approval file is missing"] }; + } + try { + const data = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const result = validateProductionMutationApproval(data, unit); + return { path, approved: result.approved, reasons: result.reasons }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + path, + approved: false, + reasons: [`approval file is not valid JSON: ${message}`], + }; + } +} diff --git a/src/resources/extensions/sf/tests/production-mutation-approval.test.ts b/src/resources/extensions/sf/tests/production-mutation-approval.test.ts new file mode 100644 index 000000000..cd0f7f2ca --- /dev/null +++ b/src/resources/extensions/sf/tests/production-mutation-approval.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + ensureProductionMutationApprovalTemplate, + type ProductionMutationApproval, + type ProductionMutationUnit, + productionMutationApprovalPath, + readProductionMutationApprovalStatus, + validateProductionMutationApproval, +} from "../production-mutation-approval.ts"; + +function tempBase(): string { + return mkdtempSync(join(tmpdir(), "sf-production-approval-")); +} + +const unit: ProductionMutationUnit = { + milestoneId: "M004", + sliceId: "S03", + taskId: "T01", + taskTitle: "End-to-End R005 Smoke Test", + taskText: [ + "Execute smoke test against production NixOS Hetzner infrastructure.", + "POST to /action/unified-failover with test server_id and vm_names.", + "Verify command row was created with status=pending.", + ].join("\n"), +}; + +test("production mutation approval writes a pending template once", () => { + const basePath = tempBase(); + try { + const first = ensureProductionMutationApprovalTemplate(basePath, unit); + assert.equal(first.created, true); + assert.equal(existsSync(first.path), true); + assert.equal(first.path, productionMutationApprovalPath(basePath, unit)); + + const parsed = JSON.parse( + readFileSync(first.path, "utf-8"), + ) as ProductionMutationApproval; + assert.equal(parsed.schemaVersion, 1); + assert.equal(parsed.unitId, "M004/S03/T01"); + assert.equal(parsed.status, "pending"); + assert.equal(parsed.approval.approved, false); + assert.deepEqual(parsed.approval.safeVmNames, []); + + const second = ensureProductionMutationApprovalTemplate(basePath, unit); + assert.equal(second.created, false); + assert.equal(second.path, first.path); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +}); + +test("production mutation approval rejects incomplete approval files", () => { + const result = validateProductionMutationApproval( + { + schemaVersion: 1, + unitId: "M004/S03/T01", + status: "pending", + risk: "production-unified-failover-post", + approval: { + approved: true, + approvedBy: "", + approvedAt: "", + safeServerId: "", + safeVmNames: [], + cleanupPlan: "", + rollbackPlan: "", + }, + }, + unit, + ); + + assert.equal(result.approved, false); + assert.ok(result.reasons.includes("status must be approved")); + assert.ok(result.reasons.includes("approval.approvedBy is required")); + assert.ok( + result.reasons.includes( + "approval.safeVmNames must contain at least one VM name", + ), + ); +}); + +test("production mutation approval accepts complete explicit approval", () => { + const basePath = tempBase(); + try { + const path = productionMutationApprovalPath(basePath, unit); + const approval: ProductionMutationApproval = { + schemaVersion: 1, + unitId: "M004/S03/T01", + status: "approved", + risk: "production-unified-failover-post", + task: { + title: unit.taskTitle, + text: unit.taskText, + }, + approval: { + approved: true, + approvedBy: "operator@example.com", + approvedAt: "2026-04-30T10:00:00.000Z", + safeServerId: "test-server-id", + safeVmNames: ["pilot-smoke-vm"], + cleanupPlan: "Delete the test pending command after verification.", + rollbackPlan: "Abort the test failover and restore the previous route.", + notes: "Smoke target is non-customer-impacting.", + }, + instructions: [], + }; + ensureProductionMutationApprovalTemplate(basePath, unit); + writeFileSync(path, JSON.stringify(approval, null, 2) + "\n", "utf-8"); + + const status = readProductionMutationApprovalStatus(basePath, unit); + assert.equal(status.approved, true); + assert.deepEqual(status.reasons, []); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +});