import fs from "fs"; import path from "path"; import type { ReleaseExecutionPlan } from "./executionPlan"; export interface OperatorExecutionReceipt { transactionId: string; transactionHash: string; } export interface OperatorExecutionResult { version: 1; taskId: string; target: string; executionId: string; executionPlanHash: string; receipts: OperatorExecutionReceipt[]; createdAt: string; updatedAt: string; } const resultPath = ( taskDir: string, target: string, executionId: string, ): string => path.join( taskDir, "results", target, executionId, "operator-execution.json", ); const validate = ( value: unknown, plan: ReleaseExecutionPlan, ): OperatorExecutionResult => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("operator execution result must be an object"); } const result = value as Record; const expectedKeys = [ "version", "taskId", "target", "executionId", "executionPlanHash", "receipts", "createdAt", "updatedAt", ].sort(); if (JSON.stringify(Object.keys(result).sort()) !== JSON.stringify(expectedKeys)) { throw new Error("operator execution result contains unsupported or missing fields"); } if ( result.version !== 1 || result.taskId !== plan.taskId || result.target !== plan.target || result.executionId !== plan.executionId || result.executionPlanHash !== plan.planHash || !Array.isArray(result.receipts) ) { throw new Error("operator execution result identity does not match the execution plan"); } const expectedTransactions = plan.transactions.filter((transaction) => ( transaction.route === "operator" )); if (result.receipts.length > expectedTransactions.length) { throw new Error("operator execution result contains too many receipts"); } const receipts = result.receipts.map((raw, index): OperatorExecutionReceipt => { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new Error(`operator receipt ${index} must be an object`); } const receipt = raw as Record; if (JSON.stringify(Object.keys(receipt).sort()) !== JSON.stringify([ "transactionHash", "transactionId", ])) { throw new Error(`operator receipt ${index} contains unsupported fields`); } if ( receipt.transactionId !== expectedTransactions[index].transactionId || typeof receipt.transactionHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(receipt.transactionHash) ) { throw new Error(`operator receipt ${index} does not match the execution plan`); } return { transactionId: receipt.transactionId, transactionHash: receipt.transactionHash.toLowerCase(), }; }); if (typeof result.createdAt !== "string" || !Number.isFinite(Date.parse(result.createdAt))) { throw new Error("operator execution result createdAt is invalid"); } if (typeof result.updatedAt !== "string" || !Number.isFinite(Date.parse(result.updatedAt))) { throw new Error("operator execution result updatedAt is invalid"); } return { version: 1, taskId: plan.taskId, target: plan.target, executionId: plan.executionId, executionPlanHash: plan.planHash, receipts, createdAt: result.createdAt, updatedAt: result.updatedAt, }; }; export const writeOperatorExecutionResult = (input: { taskDir: string; plan: ReleaseExecutionPlan; receipts: OperatorExecutionReceipt[]; now?: () => string; }): { filePath: string; result: OperatorExecutionResult } => { const filePath = resultPath(input.taskDir, input.plan.target, input.plan.executionId); const existing = fs.existsSync(filePath) ? loadOperatorExecutionResult({ taskDir: input.taskDir, plan: input.plan }) : undefined; const expectedTransactions = input.plan.transactions.filter((transaction) => ( transaction.route === "operator" )); const offset = existing?.receipts.length ?? 0; for (const [index, receipt] of input.receipts.entries()) { if (receipt.transactionId !== expectedTransactions[offset + index]?.transactionId) { throw new Error("operator receipt does not match the next operator transaction"); } } if (offset + input.receipts.length > expectedTransactions.length) { throw new Error("operator execution result contains too many receipts"); } if (existing && input.receipts.length === 0) { return { filePath, result: existing }; } const now = (input.now || (() => new Date().toISOString()))(); const result = validate({ version: 1, taskId: input.plan.taskId, target: input.plan.target, executionId: input.plan.executionId, executionPlanHash: input.plan.planHash, receipts: [...(existing?.receipts ?? []), ...input.receipts], createdAt: existing?.createdAt ?? now, updatedAt: now, }, input.plan); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const serialized = JSON.stringify(result, null, 2) + "\n"; const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; try { fs.writeFileSync(temporary, serialized, { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, filePath); } catch (error) { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); throw error; } return { filePath, result }; }; export const loadOperatorExecutionResult = (input: { taskDir: string; plan: ReleaseExecutionPlan; }): OperatorExecutionResult => { const filePath = resultPath(input.taskDir, input.plan.target, input.plan.executionId); let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (error) { throw new Error( `Cannot load operator execution result: ${error instanceof Error ? error.message : String(error)}`, ); } return validate(parsed, input.plan); };