import fs from "fs"; import path from "path"; import type { WorkflowConfig } from "./config"; import { writeJsonFile } from "./config"; import { stageContractInfoCandidate } from "./contractInfoCandidates"; import { cloneContractInfo, ContractInfo, contractInfoKey, deploymentInstanceId, loadContractInfo, } from "./deployments"; export type DeploymentExecutionStatus = | "prepared" | "applied" | "waiting_code_check" | "waiting_external" | "partial"; export type DeploymentChangeStatus = | "prepared" | "applied" | "waiting_code_check" | "waiting_external" | "failed"; export interface GateToolEvidence { outputPath: string; data: Record; } export interface DeploymentChange { stepId: string; kind: "deploy" | "upgrade"; instanceId: string; actualContract: string; contractInfoKey: string; status?: DeploymentChangeStatus; contractInfoChanged?: boolean; strategy?: string; proxyKind?: string; address?: string; proxyAddress?: string; implementationAddress?: string; preparedImplementationAddress?: string; placeholderImplementationAddress?: string; implementationTxHash?: string; implementationSalt?: string; factory?: string; multicall?: string; salt?: string; expectedAddress?: string; predictedAddress?: string; leadingZeroBytes?: number; txHash?: string; reused?: boolean; alreadyDeployed?: boolean; redeployReason?: string; previousAddress?: string; candidateAddress?: string; candidatePath?: string; backupPath?: string; gateTool?: GateToolEvidence; } export interface DeploymentExecutionRecord { taskId: string; executionId: string; network: string; status: DeploymentExecutionStatus; startedAt: string; updatedAt: string; candidatePath?: string; backupPath?: string; candidateStatus?: "pending" | "promoted"; contractInfo: { before: ContractInfo; planned: ContractInfo; after?: ContractInfo; }; changes: Array; external?: Record; failure?: { name?: string; message: string; }; } export const deploymentRecordPath = ( taskDir: string, network: string, executionId: string, ): string => path.join(taskDir, "results", `${network}.${executionId}.deployment.json`); export const markDeploymentRecordReconciled = (input: { taskDir: string; taskId: string; network: string; executionId: string; reportPath: string; reportHash: string; }): DeploymentExecutionRecord => { const filePath = deploymentRecordPath(input.taskDir, input.network, input.executionId); let record: DeploymentExecutionRecord; try { record = JSON.parse(fs.readFileSync(filePath, "utf8")) as DeploymentExecutionRecord; } catch (error) { throw new Error( `Cannot load deployment record for reconciliation: ${error instanceof Error ? error.message : String(error)}`, ); } if ( record.taskId !== input.taskId || record.network !== input.network || record.executionId !== input.executionId ) { throw new Error("Deployment record identity does not match reconciliation evidence"); } if (record.status !== "waiting_external" && record.status !== "waiting_code_check") { throw new Error(`Deployment record cannot be reconciled from status ${record.status}`); } if (record.status === "waiting_code_check") { const reconciliation = record.external?.reconciliation as Record | undefined; if ( reconciliation?.reportPath === input.reportPath && reconciliation?.reportHash === input.reportHash ) { return record; } throw new Error("Deployment record was already reconciled with different evidence"); } const next: DeploymentExecutionRecord = { ...record, status: "waiting_code_check", updatedAt: new Date().toISOString(), changes: record.changes.map((change) => ( change.status === "waiting_external" || change.status === "prepared" ? { ...change, status: "waiting_code_check" } : change )), external: { ...(record.external || {}), reconciliation: { status: "reconciled", reportPath: input.reportPath, reportHash: input.reportHash, }, }, }; writeJsonFile(filePath, next); return next; }; export interface DeploymentRecorderOptions { root: string; taskDir: string; config: WorkflowConfig; taskId: string; executionId: string; network: string; awaitRegistryCommit?: boolean; resume?: boolean; } const errorDetails = (error: unknown): { name?: string; message: string } => { if (error instanceof Error) { return { name: error.name, message: error.message }; } return { message: String(error) }; }; export class DeploymentRecorder { readonly filePath: string; private readonly root: string; private readonly config: WorkflowConfig; private readonly record: DeploymentExecutionRecord; private readonly awaitRegistryCommit: boolean; constructor(options: DeploymentRecorderOptions) { this.root = options.root; this.config = options.config; this.awaitRegistryCommit = options.awaitRegistryCommit === true; this.filePath = deploymentRecordPath( options.taskDir, options.network, options.executionId, ); if (options.resume === true) { this.record = this.loadResumableRecord(options); } else { const startedAt = new Date().toISOString(); const before = cloneContractInfo(loadContractInfo(options.root, options.config)); this.record = { taskId: options.taskId, executionId: options.executionId, network: options.network, status: "prepared", startedAt, updatedAt: startedAt, contractInfo: { before, planned: cloneContractInfo(before), }, changes: [], }; this.persistInitial(); } } prepare(change: DeploymentChange): void { deploymentInstanceId(change.instanceId); this.upsertChange(change, "prepared", false); this.record.status = "prepared"; delete this.record.failure; this.persist(); } stageAddressCandidate(change: DeploymentChange, address: string): void { const canonicalKey = contractInfoKey(change.instanceId, change.actualContract); if (change.contractInfoKey !== canonicalKey) { throw new Error( `Deployment record key ${change.contractInfoKey} does not match canonical key ${canonicalKey}`, ); } const staged = stageContractInfoCandidate( { root: this.root, taskDir: path.dirname(path.dirname(this.filePath)), config: this.config, deploymentId: this.record.taskId, executionId: this.record.executionId, network: this.record.network, }, { instanceId: change.instanceId, contractName: change.actualContract, address, }, ); this.record.candidatePath = staged.candidatePath; this.record.backupPath = staged.backupPath; this.record.candidateStatus = "pending"; this.record.contractInfo.planned = cloneContractInfo(staged.candidate.contractInfo); this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); this.upsertChange( { ...change, address, previousAddress: staged.replacement.previousAddress, candidateAddress: staged.replacement.candidateAddress, candidatePath: staged.candidatePath, backupPath: staged.backupPath, }, "prepared", true, ); this.record.status = "prepared"; this.persist(); } applyAddress(change: DeploymentChange, address: string): void { const canonicalKey = contractInfoKey(change.instanceId, change.actualContract); if (change.contractInfoKey !== canonicalKey) { throw new Error( `Deployment record key ${change.contractInfoKey} does not match canonical key ${canonicalKey}`, ); } const preparedChange = { ...change, address, candidateAddress: address, contractInfoKey: canonicalKey, }; this.upsertChange(preparedChange, "prepared", true); this.record.status = "prepared"; this.persist(); try { const staged = stageContractInfoCandidate( { root: this.root, taskDir: path.dirname(path.dirname(this.filePath)), config: this.config, deploymentId: this.record.taskId, executionId: this.record.executionId, network: this.record.network, }, { instanceId: change.instanceId, contractName: change.actualContract, address, }, ); const pendingChange = { ...preparedChange, contractInfoKey: staged.replacement.candidateKey, previousAddress: staged.replacement.previousAddress, candidateAddress: staged.replacement.candidateAddress, candidatePath: staged.candidatePath, backupPath: staged.backupPath, }; this.record.candidatePath = staged.candidatePath; this.record.backupPath = staged.backupPath; this.record.candidateStatus = "pending"; this.record.contractInfo.planned = cloneContractInfo(staged.candidate.contractInfo); this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); this.upsertChange(pendingChange, "waiting_code_check", true); this.record.status = "waiting_code_check"; delete this.record.failure; this.persist(); } catch (error) { this.upsertChange(preparedChange, "failed", true); this.record.status = "partial"; this.record.failure = errorDetails(error); this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); this.persist(); throw error; } } apply(change: DeploymentChange): void { const current = cloneContractInfo(loadContractInfo(this.root, this.config)); this.record.contractInfo.planned = current; this.record.contractInfo.after = cloneContractInfo(current); this.upsertChange(change, "applied", false); this.updateAppliedStatus(); this.persist(); } markWaitingExternal(external: Record): void { this.record.status = "waiting_external"; this.record.external = external; this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); this.record.changes = this.record.changes.map((change) => ( change.status === "prepared" ? { ...change, status: "waiting_external" } : change )); this.persist(); } markPartial(error: unknown): void { this.record.status = "partial"; this.record.failure = errorDetails(error); this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); this.record.changes = this.record.changes.map((change) => ( change.status === "prepared" || change.status === "waiting_external" ? { ...change, status: "failed" } : change )); this.persist(); } complete(): DeploymentExecutionStatus { this.record.contractInfo.after = cloneContractInfo( loadContractInfo(this.root, this.config), ); if ( this.awaitRegistryCommit && this.record.changes.every((change) => change.status !== "failed") ) { this.record.status = "waiting_code_check"; } else if (this.record.changes.some((change) => change.status === "waiting_code_check")) { this.record.status = "waiting_code_check"; } else { this.record.status = this.record.changes.every((change) => change.status === "applied") ? "applied" : "partial"; } this.persist(); return this.record.status; } private upsertChange( change: DeploymentChange, status: DeploymentChangeStatus, contractInfoChanged: boolean, ): void { const index = this.record.changes.findIndex((item) => item.stepId === change.stepId); const existing = index >= 0 ? this.record.changes[index] : undefined; const next = { ...existing, ...change, status, contractInfoChanged: existing?.contractInfoChanged || contractInfoChanged, }; if (index >= 0) { this.record.changes[index] = next; } else { this.record.changes.push(next); } } private updateAppliedStatus(): void { if (this.record.changes.some((change) => change.status === "waiting_code_check")) { this.record.status = "waiting_code_check"; } else { this.record.status = this.record.changes.every((change) => change.status === "applied") ? "applied" : "prepared"; } delete this.record.failure; } private persist(): void { this.record.updatedAt = new Date().toISOString(); writeJsonFile(this.filePath, this.record); } private persistInitial(): void { this.record.updatedAt = new Date().toISOString(); fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); let file: number; try { file = fs.openSync(this.filePath, "wx"); } catch (error) { if ( error && typeof error === "object" && "code" in error && String((error as { code?: unknown }).code) === "EEXIST" ) { throw new Error(`Deployment record already exists: ${this.filePath}`); } throw error; } try { fs.writeFileSync(file, `${JSON.stringify(this.record, null, 2)}\n`); fs.fsyncSync(file); } catch (error) { fs.closeSync(file); fs.unlinkSync(this.filePath); throw error; } fs.closeSync(file); } private loadResumableRecord(options: DeploymentRecorderOptions): DeploymentExecutionRecord { let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")); } catch (error) { throw new Error( `Cannot resume deployment record: ${error instanceof Error ? error.message : String(error)}`, ); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Cannot resume an invalid deployment record"); } const record = parsed as DeploymentExecutionRecord; if ( record.taskId !== options.taskId || record.executionId !== options.executionId || record.network !== options.network || (record.status !== "waiting_external" && record.status !== "partial") || !record.contractInfo || !Array.isArray(record.changes) ) { throw new Error("Deployment record is not resumable for this execution"); } return record; } }