mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): preserve terminal run history
This commit is contained in:
@@ -154,6 +154,13 @@ import {
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
} from './reconciliation/application/automation/applyCoordinator';
|
||||
import {
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
verifyLocalReconciliationRunHistory,
|
||||
verifyLocalReconciliationRunHistoryCommandFile,
|
||||
} from './reconciliation/application/run-history/coordinator';
|
||||
import {
|
||||
completeLocalReconciliation,
|
||||
completeLocalReconciliationCommandFile,
|
||||
@@ -199,6 +206,11 @@ export {
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
verifyLocalReconciliationRunHistory,
|
||||
verifyLocalReconciliationRunHistoryCommandFile,
|
||||
completeLocalReconciliation,
|
||||
completeLocalReconciliationCommandFile,
|
||||
verifyLocalReconciliationCompletion,
|
||||
@@ -212,6 +224,8 @@ export {
|
||||
type LocalReconciliationCompletionAutomationBinding,
|
||||
type LocalReconciliationCompletionAutomationOptions,
|
||||
type LocalReconciliationCompletionOptions,
|
||||
type LocalReconciliationCompletionRunHistoryBinding,
|
||||
type LocalReconciliationCompletionRunHistoryOptions,
|
||||
type LocalReconciliationCompletionResult,
|
||||
type LocalReconciliationCompletionVerifyCommand,
|
||||
} from './reconciliation/completion/contract';
|
||||
@@ -234,6 +248,23 @@ export {
|
||||
} from './reconciliation/application/automation/applyContract';
|
||||
export { type LocalReconciliationAutomationApplyDependencies } from './reconciliation/application/automation/applyCoordinator';
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationRunHistoryPreserveCommand,
|
||||
normalizeLocalReconciliationRunHistoryVerifyCommand,
|
||||
type LocalReconciliationRunHistoryOptions,
|
||||
type LocalReconciliationRunHistoryPreserveCommand,
|
||||
type LocalReconciliationRunHistoryResult,
|
||||
type LocalReconciliationRunHistoryVerifyCommand,
|
||||
} from './reconciliation/application/run-history/contract';
|
||||
export {
|
||||
type LocalReconciliationRunHistoryDependencies,
|
||||
type LocalReconciliationRunHistoryTerminal,
|
||||
} from './reconciliation/application/run-history/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationRunHistoryPreservationReceipt,
|
||||
type LocalReconciliationRunHistoryPreservationReceipt,
|
||||
} from './reconciliation/application/run-history/evidence';
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationApplicationCommitCommand,
|
||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
applyLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
verifyLocalReconciliationRunHistoryCommandFile,
|
||||
completeLocalReconciliationCommandFile,
|
||||
verifyLocalReconciliationCompletionCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
@@ -50,7 +52,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -100,6 +102,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-automation-apply' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||
argv[0] !== 'reconciliation-run-history-preserve' &&
|
||||
argv[0] !== 'reconciliation-run-history-verify' &&
|
||||
argv[0] !== 'reconciliation-complete' &&
|
||||
argv[0] !== 'reconciliation-complete-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
@@ -214,6 +218,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-preserve'
|
||||
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-verify'
|
||||
? verifyLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-complete'
|
||||
? completeLocalReconciliationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-complete-verify'
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_PATH = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
|
||||
export interface LocalReconciliationRunHistoryOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly runHistoryRoot: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationRunHistoryPreserveCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.run-history.preserve';
|
||||
readonly options: Readonly<LocalReconciliationRunHistoryOptions>;
|
||||
readonly request: Readonly<{
|
||||
preservationId: string;
|
||||
applicationId: string;
|
||||
expectedApplicationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
decisionFilePath: string;
|
||||
preservedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationRunHistoryVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.run-history.verify';
|
||||
readonly options: Readonly<LocalReconciliationRunHistoryOptions>;
|
||||
readonly request: Readonly<{
|
||||
preservationId: string;
|
||||
applicationId: string;
|
||||
expectedPreservationDigest: string;
|
||||
decisionFilePath: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationRunHistoryResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| LocalReconciliationRunHistoryPreserveCommand['operation']
|
||||
| LocalReconciliationRunHistoryVerifyCommand['operation'];
|
||||
readonly status: 'preserved' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_run_history_preserved';
|
||||
readonly preservationId: string;
|
||||
readonly applicationId: string;
|
||||
readonly preservationDigest: string;
|
||||
readonly legacyFactCount: number;
|
||||
readonly targetFactCount: number;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation run history ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
fail(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
fail(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > 4_096
|
||||
) {
|
||||
fail(`${label} must be a safe non-root absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function identifier(value: unknown, pattern: RegExp, label: string): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value))
|
||||
fail(`${label} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationRunHistoryOptions> {
|
||||
const selected = record(value, 'options');
|
||||
exact(
|
||||
selected,
|
||||
['allowRootService', 'applicationRoot', 'deploymentRoot', 'runHistoryRoot'],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof selected.allowRootService !== 'boolean' ||
|
||||
(currentIdentity().uid === 0) !== selected.allowRootService
|
||||
) {
|
||||
fail('command identity is invalid');
|
||||
}
|
||||
const roots = [
|
||||
safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||
safePath(selected.applicationRoot, 'applicationRoot'),
|
||||
safePath(selected.runHistoryRoot, 'runHistoryRoot'),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
overlaps(roots[left]!, roots[right]!) ||
|
||||
overlaps(roots[right]!, roots[left]!)
|
||||
) {
|
||||
fail('authority roots overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: roots[0]!,
|
||||
applicationRoot: roots[1]!,
|
||||
runHistoryRoot: roots[2]!,
|
||||
allowRootService: selected.allowRootService,
|
||||
});
|
||||
}
|
||||
|
||||
function command(value: unknown, operation: string) {
|
||||
const selected = record(value, 'command');
|
||||
exact(
|
||||
selected,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
fail('command version or operation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
request: record(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
function decisionFilePath(
|
||||
value: unknown,
|
||||
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||
): string {
|
||||
const selected = safePath(value, 'decisionFilePath');
|
||||
for (const root of [
|
||||
options.deploymentRoot,
|
||||
options.applicationRoot,
|
||||
options.runHistoryRoot,
|
||||
]) {
|
||||
if (overlaps(root, selected) || overlaps(selected, root)) {
|
||||
fail('decisionFilePath overlaps an authority root');
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationRunHistoryPreserveCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationRunHistoryPreserveCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.run-history.preserve',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'decisionFilePath',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'preservationId',
|
||||
'preservedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.preservedAtMs) ||
|
||||
(selected.request.preservedAtMs as number) < 0
|
||||
) {
|
||||
fail('preservedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.run-history.preserve',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
preservationId: identifier(
|
||||
selected.request.preservationId,
|
||||
UUID_V4,
|
||||
'preservationId',
|
||||
),
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
UUID_V4,
|
||||
'applicationId',
|
||||
),
|
||||
expectedApplicationPlanDigest: identifier(
|
||||
selected.request.expectedApplicationPlanDigest,
|
||||
DIGEST,
|
||||
'expectedApplicationPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: identifier(
|
||||
selected.request.expectedHeadDigest,
|
||||
DIGEST,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
decisionFilePath: decisionFilePath(
|
||||
selected.request.decisionFilePath,
|
||||
selected.options,
|
||||
),
|
||||
preservedAtMs: selected.request.preservedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationRunHistoryVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationRunHistoryVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.run-history.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'decisionFilePath',
|
||||
'expectedPreservationDigest',
|
||||
'preservationId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.run-history.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
preservationId: identifier(
|
||||
selected.request.preservationId,
|
||||
UUID_V4,
|
||||
'preservationId',
|
||||
),
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
UUID_V4,
|
||||
'applicationId',
|
||||
),
|
||||
expectedPreservationDigest: identifier(
|
||||
selected.request.expectedPreservationDigest,
|
||||
DIGEST,
|
||||
'expectedPreservationDigest',
|
||||
),
|
||||
decisionFilePath: decisionFilePath(
|
||||
selected.request.decisionFilePath,
|
||||
selected.options,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../../../foundation/files';
|
||||
import { readLocalCutoverInstanceHead } from '../../../cutover/instanceLineage';
|
||||
import { readLocalReconciliationPlanTerminal } from '../../planning/preparation';
|
||||
import {
|
||||
assertLocalReconciliationReviewDecisionMatchesFact,
|
||||
withLocalReconciliationReviewDecisionFile,
|
||||
} from '../../review/decisionFile';
|
||||
import { visitLocalReconciliationDiagnosticFacts } from '../../review/diagnostics';
|
||||
import { readLocalReconciliationReviewTerminalJson } from '../../review/terminalEvidence';
|
||||
import {
|
||||
withLocalReconciliationSealedDatabase,
|
||||
type LocalReconciliationSealedBundleReaderDependencies,
|
||||
} from '../../sealed-bundle/reader';
|
||||
import {
|
||||
readLocalReconciliationApplicationTerminal,
|
||||
type LocalReconciliationApplicationTerminal,
|
||||
} from '../coordinator';
|
||||
import {
|
||||
normalizeLocalReconciliationRunHistoryPreserveCommand,
|
||||
normalizeLocalReconciliationRunHistoryVerifyCommand,
|
||||
type LocalReconciliationRunHistoryOptions,
|
||||
type LocalReconciliationRunHistoryPreserveCommand,
|
||||
type LocalReconciliationRunHistoryResult,
|
||||
type LocalReconciliationRunHistoryVerifyCommand,
|
||||
} from './contract';
|
||||
import {
|
||||
buildLocalReconciliationRunHistoryPreservationReceipt,
|
||||
localReconciliationRunHistoryReceiptContents,
|
||||
normalizeLocalReconciliationRunHistoryPreservationReceipt,
|
||||
type LocalReconciliationRunHistoryPreservationReceipt,
|
||||
} from './evidence';
|
||||
|
||||
const MAX_PRESERVATIONS = 64;
|
||||
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||
|
||||
interface RunHistoryPaths {
|
||||
readonly root: string;
|
||||
readonly receipt: string;
|
||||
}
|
||||
|
||||
interface RunHistoryAuthority {
|
||||
readonly application: Readonly<LocalReconciliationApplicationTerminal>;
|
||||
readonly runHistoryInventoryDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly decisionFileDigest: string;
|
||||
readonly legacyFactCount: number;
|
||||
readonly targetFactCount: number;
|
||||
confirmDecisionFileIdentity(): void;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationRunHistoryDependencies
|
||||
extends LocalReconciliationSealedBundleReaderDependencies {
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterTerminalSealed?: () => void;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationRunHistoryTerminal {
|
||||
readonly receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||
readonly application: Readonly<LocalReconciliationApplicationTerminal>;
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation run history ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function runHistoryPaths(
|
||||
runHistoryRoot: string,
|
||||
preservationId: string,
|
||||
): Readonly<RunHistoryPaths> {
|
||||
const root = path.join(runHistoryRoot, preservationId);
|
||||
return Object.freeze({ root, receipt: path.join(root, 'receipt.json') });
|
||||
}
|
||||
|
||||
function validateDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
label: string,
|
||||
): number {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
return fail(`${label} is unavailable`, error);
|
||||
}
|
||||
const mode = stat.mode & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
!modes.includes(mode) ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
fail(`${label} identity is invalid`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function validateCatalog(selected: Readonly<RunHistoryPaths>, sealed: boolean) {
|
||||
const allowed = new Set([
|
||||
'receipt.json',
|
||||
...(!sealed ? ['.receipt.json.ql3-deploy-stage'] : []),
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
fail('preservation catalog contains unknown material');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePreservationDirectory(
|
||||
runHistoryRoot: string,
|
||||
preservationId: string,
|
||||
uid: number,
|
||||
): Readonly<RunHistoryPaths> {
|
||||
const selected = runHistoryPaths(runHistoryRoot, preservationId);
|
||||
const entries = fs.readdirSync(runHistoryRoot, { withFileTypes: true });
|
||||
if (entries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink())) {
|
||||
fail('preservation root catalog contains drift');
|
||||
}
|
||||
if (entries.length >= MAX_PRESERVATIONS && !fs.existsSync(selected.root)) {
|
||||
fail('preservation retention limit is reached');
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(selected.root, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
fail('preservation directory cannot be created', error);
|
||||
}
|
||||
}
|
||||
const mode = validateDirectory(
|
||||
selected.root,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'preservation directory',
|
||||
);
|
||||
validateCatalog(selected, mode === 0o500);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function readReceipt(
|
||||
selected: Readonly<RunHistoryPaths>,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(selected.receipt);
|
||||
} catch (error) {
|
||||
return fail('preservation receipt is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
!modes.includes(stat.mode & 0o777) ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RECEIPT_BYTES
|
||||
) {
|
||||
fail('preservation receipt identity is invalid');
|
||||
}
|
||||
try {
|
||||
return normalizeLocalReconciliationRunHistoryPreservationReceipt(
|
||||
readLocalReconciliationReviewTerminalJson(selected.receipt, uid, modes),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return fail('preservation receipt cannot be read', error);
|
||||
}
|
||||
}
|
||||
|
||||
function sealPreservation(
|
||||
selected: Readonly<RunHistoryPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
validateDirectory(
|
||||
selected.root,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'preservation directory',
|
||||
);
|
||||
readReceipt(selected, uid, [0o600, 0o400]);
|
||||
let descriptor = fs.openSync(
|
||||
selected.receipt,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
|
||||
fail('preservation receipt cannot be sealed');
|
||||
}
|
||||
if ((stat.mode & 0o777) !== 0o400) {
|
||||
fs.fchmodSync(descriptor, 0o400);
|
||||
fs.fsyncSync(descriptor);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
descriptor = fs.openSync(selected.root, fs.constants.O_RDONLY);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if ((stat.mode & 0o777) !== 0o500) fs.fchmodSync(descriptor, 0o500);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
validateCatalog(selected, true);
|
||||
readReceipt(selected, uid, [0o400]);
|
||||
}
|
||||
|
||||
function validateApplication(
|
||||
application: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||
applicationId: string,
|
||||
applicationPlanDigest: string,
|
||||
): void {
|
||||
const runHistory = application.plan.domains.find(
|
||||
(domain) => domain.domain === 'run_history',
|
||||
);
|
||||
if (
|
||||
application.intent.command.options.deploymentRoot !==
|
||||
options.deploymentRoot ||
|
||||
application.intent.command.options.applicationRoot !==
|
||||
options.applicationRoot ||
|
||||
application.plan.applicationId !== applicationId ||
|
||||
application.plan.applicationPlanDigest !== applicationPlanDigest ||
|
||||
!runHistory ||
|
||||
runHistory.action !== 'adapter_required'
|
||||
) {
|
||||
fail('preservation authority is detached from the application plan');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSourceHead(
|
||||
application: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
expectedHeadDigest: string,
|
||||
uid: number,
|
||||
): void {
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
application.intent.command.options.deploymentRoot,
|
||||
application.intent.instanceId,
|
||||
uid,
|
||||
);
|
||||
const automation = application.plan.domains.find(
|
||||
(domain) => domain.domain === 'automation',
|
||||
);
|
||||
const expectedState =
|
||||
automation?.action === 'no_effect'
|
||||
? 'reconciliation_application_planned'
|
||||
: automation?.action === 'adapter_required'
|
||||
? 'reconciliation_automation_applied'
|
||||
: null;
|
||||
if (
|
||||
expectedState === null ||
|
||||
head.headDigest !== expectedHeadDigest ||
|
||||
head.state !== expectedState ||
|
||||
head.instanceId !== application.intent.instanceId ||
|
||||
head.cutoverId !== application.intent.cutoverId ||
|
||||
head.generation !== application.intent.generation ||
|
||||
head.activationDigest !== application.intent.activationDigest
|
||||
) {
|
||||
fail('preservation lost source head compare-and-swap');
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveAuthority(
|
||||
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||
applicationId: string,
|
||||
applicationPlanDigest: string,
|
||||
decisionFilePath: string,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies,
|
||||
): Promise<Readonly<RunHistoryAuthority>> {
|
||||
const application = await readLocalReconciliationApplicationTerminal(
|
||||
options.applicationRoot,
|
||||
applicationId,
|
||||
uid,
|
||||
);
|
||||
validateApplication(
|
||||
application,
|
||||
options,
|
||||
applicationId,
|
||||
applicationPlanDigest,
|
||||
);
|
||||
const planTerminal = readLocalReconciliationPlanTerminal(
|
||||
application.review.intent.command.options.planRoot,
|
||||
application.review.intent.command.request.planId,
|
||||
uid,
|
||||
);
|
||||
const runHistory = planTerminal.plan.domains.find(
|
||||
(domain) => domain.domain === 'run_history',
|
||||
);
|
||||
if (!runHistory) fail('run history inventory is absent');
|
||||
let legacyFactCount = 0;
|
||||
let targetFactCount = 0;
|
||||
const reviewed = withLocalReconciliationReviewDecisionFile(
|
||||
decisionFilePath,
|
||||
{
|
||||
reviewId: application.review.review.reviewId,
|
||||
profile: application.plan.profile,
|
||||
planDigest: planTerminal.plan.planDigest,
|
||||
preparationDigest: application.review.intent.preparationDigest,
|
||||
},
|
||||
(cursor) => {
|
||||
for (const database of ['legacy', 'target'] as const) {
|
||||
const opened = withLocalReconciliationSealedDatabase(
|
||||
planTerminal.bundle,
|
||||
database,
|
||||
uid,
|
||||
dependencies,
|
||||
(client) =>
|
||||
visitLocalReconciliationDiagnosticFacts(
|
||||
client,
|
||||
database,
|
||||
(fact) => {
|
||||
if (fact.decisionRequirement === 'informational') return;
|
||||
const decision = cursor.next();
|
||||
if (decision === null) {
|
||||
fail('decision file omitted a canonical fact');
|
||||
}
|
||||
assertLocalReconciliationReviewDecisionMatchesFact(
|
||||
decision,
|
||||
fact,
|
||||
);
|
||||
if (fact.domain !== 'run_history') return;
|
||||
if (
|
||||
fact.decisionRequirement !== 'required' ||
|
||||
fact.reason !== 'historical_preservation_required' ||
|
||||
(database === 'legacy' &&
|
||||
decision.disposition !== 'retain_both') ||
|
||||
(database === 'target' &&
|
||||
decision.disposition !== 'retain_target')
|
||||
) {
|
||||
fail(
|
||||
'signed review did not authorize append-only preservation',
|
||||
);
|
||||
}
|
||||
if (database === 'legacy') legacyFactCount += 1;
|
||||
else targetFactCount += 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
if (opened === null) {
|
||||
fail('manual-required SQLite topology cannot be preserved');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (
|
||||
reviewed.evidence.fileDigest !==
|
||||
application.review.authorization.decisionFileDigest ||
|
||||
reviewed.evidence.decisionCount !==
|
||||
application.review.authorization.decisionCount ||
|
||||
legacyFactCount < 1 ||
|
||||
targetFactCount < 1
|
||||
) {
|
||||
fail('signed review authority drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
application,
|
||||
runHistoryInventoryDigest: runHistory.inventoryDigest,
|
||||
bundleDigest: planTerminal.bundle.receipt.bundleDigest,
|
||||
bundleFingerprintDigest: planTerminal.bundle.fingerprintDigest,
|
||||
decisionFileDigest: reviewed.evidence.fileDigest,
|
||||
legacyFactCount,
|
||||
targetFactCount,
|
||||
confirmDecisionFileIdentity: reviewed.confirmIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
function validateReceiptBinding(
|
||||
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||
authority: Readonly<RunHistoryAuthority>,
|
||||
preservationId: string,
|
||||
): void {
|
||||
const application = authority.application;
|
||||
if (
|
||||
receipt.preservationId !== preservationId ||
|
||||
receipt.applicationId !== application.plan.applicationId ||
|
||||
receipt.profile !== application.plan.profile ||
|
||||
receipt.instanceId !== application.intent.instanceId ||
|
||||
receipt.cutoverId !== application.intent.cutoverId ||
|
||||
receipt.generation !== application.intent.generation ||
|
||||
receipt.activationDigest !== application.intent.activationDigest ||
|
||||
receipt.applicationPlanDigest !== application.plan.applicationPlanDigest ||
|
||||
receipt.reviewDigest !== application.review.review.reviewDigest ||
|
||||
receipt.reviewAuthorizationDigest !==
|
||||
application.review.authorization.authorizationDigest ||
|
||||
receipt.reviewDecisionSetDigest !==
|
||||
application.review.authorization.decisionSetDigest ||
|
||||
receipt.reviewDecisionFileDigest !== authority.decisionFileDigest ||
|
||||
receipt.bundleDigest !== authority.bundleDigest ||
|
||||
receipt.bundleFingerprintDigest !== authority.bundleFingerprintDigest ||
|
||||
receipt.runHistoryInventoryDigest !== authority.runHistoryInventoryDigest ||
|
||||
receipt.legacyFactCount !== authority.legacyFactCount ||
|
||||
receipt.targetFactCount !== authority.targetFactCount
|
||||
) {
|
||||
fail('preservation receipt is detached from terminal authority');
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationRunHistoryResult['operation'],
|
||||
status: LocalReconciliationRunHistoryResult['status'],
|
||||
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||
): Readonly<LocalReconciliationRunHistoryResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
status,
|
||||
state: 'reconciliation_run_history_preserved',
|
||||
preservationId: receipt.preservationId,
|
||||
applicationId: receipt.applicationId,
|
||||
preservationDigest: receipt.preservationDigest,
|
||||
legacyFactCount: receipt.legacyFactCount,
|
||||
targetFactCount: receipt.targetFactCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalReconciliationRunHistoryTerminal(
|
||||
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||
preservationId: string,
|
||||
applicationId: string,
|
||||
decisionFilePath: string,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationRunHistoryTerminal>> {
|
||||
const selected = runHistoryPaths(options.runHistoryRoot, preservationId);
|
||||
validateDirectory(selected.root, uid, [0o500], 'preservation directory');
|
||||
validateCatalog(selected, true);
|
||||
const receipt = readReceipt(selected, uid, [0o400]);
|
||||
if (receipt.applicationId !== applicationId) {
|
||||
fail('preservation path binding drifted');
|
||||
}
|
||||
const authority = await deriveAuthority(
|
||||
options,
|
||||
applicationId,
|
||||
receipt.applicationPlanDigest,
|
||||
decisionFilePath,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
validateReceiptBinding(receipt, authority, preservationId);
|
||||
authority.confirmDecisionFileIdentity();
|
||||
return Object.freeze({ receipt, application: authority.application });
|
||||
}
|
||||
|
||||
export async function preserveLocalReconciliationRunHistory(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationRunHistoryResult>> {
|
||||
const command = normalizeLocalReconciliationRunHistoryPreserveCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.runHistoryRoot, 'runHistoryRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const selected = ensurePreservationDirectory(
|
||||
command.options.runHistoryRoot,
|
||||
command.request.preservationId,
|
||||
uid,
|
||||
);
|
||||
const authority = await deriveAuthority(
|
||||
command.options,
|
||||
command.request.applicationId,
|
||||
command.request.expectedApplicationPlanDigest,
|
||||
command.request.decisionFilePath,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
let receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||
let status: 'preserved' | 'existing' = 'preserved';
|
||||
if (fs.existsSync(selected.receipt)) {
|
||||
status = 'existing';
|
||||
receipt = readReceipt(selected, uid, [0o600, 0o400]);
|
||||
validateReceiptBinding(receipt, authority, command.request.preservationId);
|
||||
if (
|
||||
receipt.sourceHeadDigest !== command.request.expectedHeadDigest ||
|
||||
receipt.preservedAtMs !== command.request.preservedAtMs
|
||||
) {
|
||||
fail('preservation command is not an exact replay');
|
||||
}
|
||||
} else {
|
||||
assertSourceHead(
|
||||
authority.application,
|
||||
command.request.expectedHeadDigest,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
command.request.preservedAtMs < authority.application.plan.committedAtMs
|
||||
) {
|
||||
fail('preservation timestamp precedes application evidence');
|
||||
}
|
||||
receipt = buildLocalReconciliationRunHistoryPreservationReceipt({
|
||||
preservationId: command.request.preservationId,
|
||||
applicationId: command.request.applicationId,
|
||||
profile: authority.application.plan.profile,
|
||||
instanceId: authority.application.intent.instanceId,
|
||||
cutoverId: authority.application.intent.cutoverId,
|
||||
generation: authority.application.intent.generation,
|
||||
activationDigest: authority.application.intent.activationDigest,
|
||||
applicationPlanDigest: authority.application.plan.applicationPlanDigest,
|
||||
sourceHeadDigest: command.request.expectedHeadDigest,
|
||||
reviewDigest: authority.application.review.review.reviewDigest,
|
||||
reviewAuthorizationDigest:
|
||||
authority.application.review.authorization.authorizationDigest,
|
||||
reviewDecisionSetDigest:
|
||||
authority.application.review.authorization.decisionSetDigest,
|
||||
reviewDecisionFileDigest: authority.decisionFileDigest,
|
||||
bundleDigest: authority.bundleDigest,
|
||||
bundleFingerprintDigest: authority.bundleFingerprintDigest,
|
||||
runHistoryInventoryDigest: authority.runHistoryInventoryDigest,
|
||||
legacyFactCount: authority.legacyFactCount,
|
||||
targetFactCount: authority.targetFactCount,
|
||||
preservedAtMs: command.request.preservedAtMs,
|
||||
});
|
||||
const contents = localReconciliationRunHistoryReceiptContents(receipt);
|
||||
preflightPublishedFile(
|
||||
selected.receipt,
|
||||
contents,
|
||||
0o600,
|
||||
uid,
|
||||
'run history preservation receipt',
|
||||
);
|
||||
authority.confirmDecisionFileIdentity();
|
||||
assertSourceHead(
|
||||
authority.application,
|
||||
command.request.expectedHeadDigest,
|
||||
uid,
|
||||
);
|
||||
publishExactFile(
|
||||
selected.receipt,
|
||||
contents,
|
||||
0o600,
|
||||
uid,
|
||||
'run history preservation receipt',
|
||||
);
|
||||
dependencies.afterReceiptPublished?.();
|
||||
}
|
||||
sealPreservation(selected, uid);
|
||||
dependencies.afterTerminalSealed?.();
|
||||
authority.confirmDecisionFileIdentity();
|
||||
assertSourceHead(
|
||||
authority.application,
|
||||
command.request.expectedHeadDigest,
|
||||
uid,
|
||||
);
|
||||
return result(command.operation, status, receipt);
|
||||
}
|
||||
|
||||
export async function verifyLocalReconciliationRunHistory(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationRunHistoryResult>> {
|
||||
const command = normalizeLocalReconciliationRunHistoryVerifyCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.runHistoryRoot, 'runHistoryRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const terminal = await readLocalReconciliationRunHistoryTerminal(
|
||||
command.options,
|
||||
command.request.preservationId,
|
||||
command.request.applicationId,
|
||||
command.request.decisionFilePath,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
if (
|
||||
terminal.receipt.preservationDigest !==
|
||||
command.request.expectedPreservationDigest
|
||||
) {
|
||||
fail('verify command is detached from preservation receipt');
|
||||
}
|
||||
return result(command.operation, 'verified', terminal.receipt);
|
||||
}
|
||||
|
||||
export function preserveLocalReconciliationRunHistoryCommandFile(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||
) {
|
||||
return preserveLocalReconciliationRunHistory(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyLocalReconciliationRunHistoryCommandFile(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||
) {
|
||||
return verifyLocalReconciliationRunHistory(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
export type {
|
||||
LocalReconciliationRunHistoryPreserveCommand,
|
||||
LocalReconciliationRunHistoryVerifyCommand,
|
||||
};
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||
|
||||
const RECEIPT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-run-history-preservation-receipt';
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface LocalReconciliationRunHistoryPreservationReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_run_history_preserved';
|
||||
readonly preservationId: string;
|
||||
readonly applicationId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly sourceHeadDigest: string;
|
||||
readonly reviewDigest: string;
|
||||
readonly reviewAuthorizationDigest: string;
|
||||
readonly reviewDecisionSetDigest: string;
|
||||
readonly reviewDecisionFileDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly runHistoryInventoryDigest: string;
|
||||
readonly legacyFactCount: number;
|
||||
readonly targetFactCount: number;
|
||||
readonly preservedAtMs: number;
|
||||
readonly preservationDigest: string;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation run history evidence ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
fail('receipt must be an object');
|
||||
}
|
||||
const selected = value as Record<string, unknown>;
|
||||
const actual = Object.keys(selected).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
fail('receipt shape is invalid');
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationRunHistoryPreservationReceipt(
|
||||
input: Omit<
|
||||
LocalReconciliationRunHistoryPreservationReceipt,
|
||||
'schema' | 'schemaVersion' | 'state' | 'preservationDigest'
|
||||
>,
|
||||
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_run_history_preserved' as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preservationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationRunHistoryPreservationReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||
const selected = exact(value, [
|
||||
'activationDigest',
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyFactCount',
|
||||
'preservationDigest',
|
||||
'preservationId',
|
||||
'preservedAtMs',
|
||||
'profile',
|
||||
'reviewAuthorizationDigest',
|
||||
'reviewDecisionFileDigest',
|
||||
'reviewDecisionSetDigest',
|
||||
'reviewDigest',
|
||||
'runHistoryInventoryDigest',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sourceHeadDigest',
|
||||
'state',
|
||||
'targetFactCount',
|
||||
]);
|
||||
const { preservationDigest, ...payload } = selected;
|
||||
if (
|
||||
selected.schema !== RECEIPT_SCHEMA ||
|
||||
selected.schemaVersion !== 1 ||
|
||||
selected.state !== 'reconciliation_run_history_preserved' ||
|
||||
typeof selected.preservationId !== 'string' ||
|
||||
!UUID_V4.test(selected.preservationId) ||
|
||||
typeof selected.applicationId !== 'string' ||
|
||||
!UUID_V4.test(selected.applicationId) ||
|
||||
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
|
||||
typeof selected.instanceId !== 'string' ||
|
||||
selected.instanceId.length < 1 ||
|
||||
typeof selected.cutoverId !== 'string' ||
|
||||
selected.cutoverId.length < 1 ||
|
||||
!Number.isSafeInteger(selected.generation) ||
|
||||
(selected.generation as number) < 1 ||
|
||||
![
|
||||
selected.activationDigest,
|
||||
selected.applicationPlanDigest,
|
||||
selected.sourceHeadDigest,
|
||||
selected.reviewDigest,
|
||||
selected.reviewAuthorizationDigest,
|
||||
selected.reviewDecisionSetDigest,
|
||||
selected.reviewDecisionFileDigest,
|
||||
selected.bundleDigest,
|
||||
selected.bundleFingerprintDigest,
|
||||
selected.runHistoryInventoryDigest,
|
||||
preservationDigest,
|
||||
].every(
|
||||
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||
) ||
|
||||
!Number.isSafeInteger(selected.legacyFactCount) ||
|
||||
(selected.legacyFactCount as number) < 1 ||
|
||||
!Number.isSafeInteger(selected.targetFactCount) ||
|
||||
(selected.targetFactCount as number) < 1 ||
|
||||
!Number.isSafeInteger(selected.preservedAtMs) ||
|
||||
(selected.preservedAtMs as number) < 0 ||
|
||||
cutoverDigest(payload) !== preservationDigest
|
||||
) {
|
||||
fail('receipt binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preservationDigest,
|
||||
}) as unknown as Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||
}
|
||||
|
||||
export function localReconciliationRunHistoryReceiptContents(
|
||||
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||
): string {
|
||||
return `${JSON.stringify(receipt, null, 2)}\n`;
|
||||
}
|
||||
+134
-23
@@ -22,17 +22,28 @@ export interface LocalReconciliationCompletionOptions {
|
||||
readonly applicationRoot: string;
|
||||
readonly completionRoot: string;
|
||||
readonly automation: Readonly<LocalReconciliationCompletionAutomationOptions> | null;
|
||||
readonly runHistory: Readonly<LocalReconciliationCompletionRunHistoryOptions> | null;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionRunHistoryOptions {
|
||||
readonly runHistoryRoot: string;
|
||||
readonly decisionFilePath: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionAutomationBinding {
|
||||
readonly automationId: string;
|
||||
readonly decisionId: string;
|
||||
readonly expectedApplyDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionRunHistoryBinding {
|
||||
readonly preservationId: string;
|
||||
readonly expectedPreservationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompleteCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly operation: 'local.deployment.reconciliation.complete';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
@@ -41,12 +52,13 @@ export interface LocalReconciliationCompleteCommand {
|
||||
expectedApplicationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||
completedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly operation: 'local.deployment.reconciliation.complete.verify';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
@@ -54,6 +66,7 @@ export interface LocalReconciliationCompletionVerifyCommand {
|
||||
applicationId: string;
|
||||
expectedCompletionDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -68,7 +81,7 @@ export interface LocalReconciliationCompletionResult {
|
||||
readonly applicationId: string;
|
||||
readonly completionDigest: string;
|
||||
readonly domainCount: 8;
|
||||
readonly adapterCount: 0 | 1;
|
||||
readonly adapterCount: 0 | 1 | 2;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
@@ -169,17 +182,27 @@ function normalizeAutomationOptions(
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
schemaVersion: 1 | 2,
|
||||
): Readonly<LocalReconciliationCompletionOptions> {
|
||||
const selected = record(value, 'options');
|
||||
exact(
|
||||
selected,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automation',
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
],
|
||||
schemaVersion === 1
|
||||
? [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automation',
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
]
|
||||
: [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automation',
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
'runHistory',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
@@ -189,11 +212,16 @@ function normalizeOptions(
|
||||
fail('command identity is invalid');
|
||||
}
|
||||
const automation = normalizeAutomationOptions(selected.automation);
|
||||
const runHistory =
|
||||
schemaVersion === 1
|
||||
? null
|
||||
: normalizeRunHistoryOptions(selected.runHistory);
|
||||
const normalized = Object.freeze({
|
||||
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||
completionRoot: safePath(selected.completionRoot, 'completionRoot'),
|
||||
automation,
|
||||
runHistory,
|
||||
allowRootService: selected.allowRootService,
|
||||
}) as Readonly<LocalReconciliationCompletionOptions>;
|
||||
const roots = [
|
||||
@@ -207,6 +235,7 @@ function normalizeOptions(
|
||||
automation.automationDecisionRoot,
|
||||
automation.automationApplyRoot,
|
||||
]),
|
||||
...(runHistory === null ? [] : [runHistory.runHistoryRoot]),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
@@ -228,9 +257,34 @@ function normalizeOptions(
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
if (
|
||||
runHistory !== null &&
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, runHistory.decisionFilePath) ||
|
||||
overlaps(runHistory.decisionFilePath, root),
|
||||
)
|
||||
) {
|
||||
fail('decisionFilePath overlaps an authority root');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRunHistoryOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionRunHistoryOptions> {
|
||||
const selected = record(value, 'run history options');
|
||||
exact(
|
||||
selected,
|
||||
['decisionFilePath', 'runHistoryRoot'],
|
||||
'run history options',
|
||||
);
|
||||
return Object.freeze({
|
||||
runHistoryRoot: safePath(selected.runHistoryRoot, 'runHistoryRoot'),
|
||||
decisionFilePath: safePath(selected.decisionFilePath, 'decisionFilePath'),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAutomationBinding(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionAutomationBinding> | null {
|
||||
@@ -251,6 +305,28 @@ function normalizeAutomationBinding(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRunHistoryBinding(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionRunHistoryBinding> {
|
||||
const selected = record(value, 'run history binding');
|
||||
exact(
|
||||
selected,
|
||||
['expectedPreservationDigest', 'preservationId'],
|
||||
'run history binding',
|
||||
);
|
||||
return Object.freeze({
|
||||
preservationId: identifier(
|
||||
selected.preservationId,
|
||||
UUID_V4,
|
||||
'preservationId',
|
||||
),
|
||||
expectedPreservationDigest: digest(
|
||||
selected.expectedPreservationDigest,
|
||||
'expectedPreservationDigest',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function command(value: unknown, operation: string) {
|
||||
const selected = record(value, 'command');
|
||||
exact(
|
||||
@@ -258,11 +334,15 @@ function command(value: unknown, operation: string) {
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
if (
|
||||
(selected.schemaVersion !== 1 && selected.schemaVersion !== 2) ||
|
||||
selected.operation !== operation
|
||||
) {
|
||||
fail('command version or operation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
schemaVersion: selected.schemaVersion,
|
||||
options: normalizeOptions(selected.options, selected.schemaVersion),
|
||||
request: record(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
@@ -273,14 +353,24 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
const selected = command(value, 'local.deployment.reconciliation.complete');
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completedAtMs',
|
||||
'completionId',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
],
|
||||
selected.schemaVersion === 1
|
||||
? [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completedAtMs',
|
||||
'completionId',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
]
|
||||
: [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completedAtMs',
|
||||
'completionId',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'runHistory',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
@@ -290,7 +380,7 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
fail('completedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: selected.schemaVersion,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
@@ -313,6 +403,10 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
runHistory:
|
||||
selected.schemaVersion === 1
|
||||
? null
|
||||
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||
completedAtMs: selected.request.completedAtMs as number,
|
||||
}),
|
||||
});
|
||||
@@ -327,11 +421,24 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['applicationId', 'automation', 'completionId', 'expectedCompletionDigest'],
|
||||
selected.schemaVersion === 1
|
||||
? [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completionId',
|
||||
'expectedCompletionDigest',
|
||||
]
|
||||
: [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completionId',
|
||||
'expectedCompletionDigest',
|
||||
'runHistory',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: selected.schemaVersion,
|
||||
operation: 'local.deployment.reconciliation.complete.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
@@ -350,6 +457,10 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
||||
'expectedCompletionDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
runHistory:
|
||||
selected.schemaVersion === 1
|
||||
? null
|
||||
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+98
-5
@@ -33,6 +33,11 @@ import type {
|
||||
LocalReconciliationAutomationApplyReceipt,
|
||||
} from '../application/automation/applyEvidence';
|
||||
import { readLocalReconciliationAutomationDecisionTerminal } from '../application/automation/decisionCoordinator';
|
||||
import {
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
type LocalReconciliationRunHistoryDependencies,
|
||||
} from '../application/run-history/coordinator';
|
||||
import type { LocalReconciliationRunHistoryPreservationReceipt } from '../application/run-history/evidence';
|
||||
import type { LocalReconciliationCompletionDomainEvidence } from './evidence';
|
||||
import {
|
||||
buildLocalReconciliationCompletionReceipt,
|
||||
@@ -63,7 +68,12 @@ interface AutomationProof {
|
||||
readonly paths: ReturnType<typeof localReconciliationAutomationApplyPaths>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionDependencies {
|
||||
interface RunHistoryProof {
|
||||
readonly receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionDependencies
|
||||
extends LocalReconciliationRunHistoryDependencies {
|
||||
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterTerminalSealed?: () => void;
|
||||
@@ -71,6 +81,62 @@ export interface LocalReconciliationCompletionDependencies {
|
||||
readonly afterBackupCollected?: () => void;
|
||||
}
|
||||
|
||||
async function runHistoryProof(
|
||||
command: Readonly<LocalReconciliationCompleteCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationCompletionDependencies,
|
||||
): Promise<Readonly<RunHistoryProof> | null> {
|
||||
const domain = terminal.plan.domains.find(
|
||||
(selected) => selected.domain === 'run_history',
|
||||
);
|
||||
if (!domain) fail('run history domain is absent');
|
||||
if (domain.action === 'no_effect') {
|
||||
if (
|
||||
command.options.runHistory !== null ||
|
||||
command.request.runHistory !== null
|
||||
) {
|
||||
fail('no-effect completion must not carry run history authority');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
domain.action !== 'adapter_required' ||
|
||||
command.options.runHistory === null ||
|
||||
command.request.runHistory === null
|
||||
) {
|
||||
fail('run history domain is not terminally provable');
|
||||
}
|
||||
validatePrivateDirectory(
|
||||
command.options.runHistory.runHistoryRoot,
|
||||
uid,
|
||||
'runHistoryRoot',
|
||||
);
|
||||
const history = await readLocalReconciliationRunHistoryTerminal(
|
||||
{
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
applicationRoot: command.options.applicationRoot,
|
||||
runHistoryRoot: command.options.runHistory.runHistoryRoot,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
command.request.runHistory.preservationId,
|
||||
command.request.applicationId,
|
||||
command.options.runHistory.decisionFilePath,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
if (
|
||||
history.receipt.preservationDigest !==
|
||||
command.request.runHistory.expectedPreservationDigest ||
|
||||
history.receipt.applicationPlanDigest !==
|
||||
terminal.plan.applicationPlanDigest ||
|
||||
history.receipt.sourceHeadDigest !== command.request.expectedHeadDigest
|
||||
) {
|
||||
fail('run history preservation evidence is detached');
|
||||
}
|
||||
return Object.freeze({ receipt: history.receipt });
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation completion ${message}`,
|
||||
@@ -393,6 +459,7 @@ async function automationProof(
|
||||
function domainEvidence(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
runHistory: Readonly<RunHistoryProof> | null,
|
||||
): readonly Readonly<LocalReconciliationCompletionDomainEvidence>[] {
|
||||
return Object.freeze(
|
||||
terminal.plan.domains.map((domain) => {
|
||||
@@ -416,6 +483,18 @@ function domainEvidence(
|
||||
evidenceDigest: automation.receipt.applyDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
domain.domain === 'run_history' &&
|
||||
domain.action === 'adapter_required' &&
|
||||
runHistory !== null
|
||||
) {
|
||||
return Object.freeze({
|
||||
domain: domain.domain,
|
||||
action: 'adapter_required' as const,
|
||||
evidenceKind: 'run_history_preservation' as const,
|
||||
evidenceDigest: runHistory.receipt.preservationDigest,
|
||||
});
|
||||
}
|
||||
return fail(`${domain.domain} is not terminally reconciled`);
|
||||
}),
|
||||
);
|
||||
@@ -539,7 +618,13 @@ export async function completeLocalReconciliation(
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation);
|
||||
const runHistory = await runHistoryProof(
|
||||
command,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation, runHistory);
|
||||
const selected = ensureCompletionDirectory(
|
||||
command.options.completionRoot,
|
||||
command.request.completionId,
|
||||
@@ -572,10 +657,11 @@ export async function completeLocalReconciliation(
|
||||
assertSourceHead(head, command.request.expectedHeadDigest, automation);
|
||||
const adapterCount = domains.filter(
|
||||
(domain) => domain.action === 'adapter_required',
|
||||
).length as 0 | 1;
|
||||
).length as 0 | 1 | 2;
|
||||
const latestEvidenceAtMs = Math.max(
|
||||
terminal.plan.committedAtMs,
|
||||
automation?.receipt.appliedAtMs ?? 0,
|
||||
runHistory?.receipt.preservedAtMs ?? 0,
|
||||
);
|
||||
if (command.request.completedAtMs < latestEvidenceAtMs) {
|
||||
fail('completion timestamp precedes terminal evidence');
|
||||
@@ -682,7 +768,7 @@ export async function verifyLocalReconciliationCompletion(
|
||||
command.options.applicationRoot,
|
||||
);
|
||||
const syntheticCompleteCommand = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion: receipt.schemaVersion,
|
||||
operation: 'local.deployment.reconciliation.complete' as const,
|
||||
options: command.options,
|
||||
request: Object.freeze({
|
||||
@@ -691,6 +777,7 @@ export async function verifyLocalReconciliationCompletion(
|
||||
expectedApplicationPlanDigest: receipt.applicationPlanDigest,
|
||||
expectedHeadDigest: receipt.sourceHeadDigest,
|
||||
automation: command.request.automation,
|
||||
runHistory: command.request.runHistory,
|
||||
completedAtMs: receipt.completedAtMs,
|
||||
}),
|
||||
});
|
||||
@@ -700,7 +787,13 @@ export async function verifyLocalReconciliationCompletion(
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation);
|
||||
const runHistory = await runHistoryProof(
|
||||
syntheticCompleteCommand,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation, runHistory);
|
||||
validateReceiptBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
|
||||
@@ -13,13 +13,16 @@ const UUID_V4 =
|
||||
export interface LocalReconciliationCompletionDomainEvidence {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly action: 'no_effect' | 'adapter_required';
|
||||
readonly evidenceKind: 'application_summary' | 'automation_apply';
|
||||
readonly evidenceKind:
|
||||
| 'application_summary'
|
||||
| 'automation_apply'
|
||||
| 'run_history_preservation';
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly state: 'reconciliation_completed';
|
||||
readonly completionId: string;
|
||||
readonly applicationId: string;
|
||||
@@ -31,7 +34,7 @@ export interface LocalReconciliationCompletionReceipt {
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly sourceHeadDigest: string;
|
||||
readonly domains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[];
|
||||
readonly adapterCount: 0 | 1;
|
||||
readonly adapterCount: 0 | 1 | 2;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
@@ -65,6 +68,7 @@ function exact(
|
||||
function domainEvidence(
|
||||
value: unknown,
|
||||
expectedDomain: LocalReconciliationPlanDomain,
|
||||
schemaVersion: 1 | 2,
|
||||
): Readonly<LocalReconciliationCompletionDomainEvidence> {
|
||||
const selected = exact(
|
||||
value,
|
||||
@@ -78,9 +82,14 @@ function domainEvidence(
|
||||
expectedDomain === 'automation' &&
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'automation_apply';
|
||||
const runHistory =
|
||||
schemaVersion === 2 &&
|
||||
expectedDomain === 'run_history' &&
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'run_history_preservation';
|
||||
if (
|
||||
selected.domain !== expectedDomain ||
|
||||
(!noEffect && !automation) ||
|
||||
(!noEffect && !automation && !runHistory) ||
|
||||
typeof selected.evidenceDigest !== 'string' ||
|
||||
!DIGEST.test(selected.evidenceDigest)
|
||||
) {
|
||||
@@ -100,9 +109,14 @@ export function buildLocalReconciliationCompletionReceipt(
|
||||
'schema' | 'schemaVersion' | 'state' | 'completionDigest'
|
||||
>,
|
||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||
const schemaVersion = input.domains.some(
|
||||
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||
)
|
||||
? (2 as const)
|
||||
: (1 as const);
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion,
|
||||
state: 'reconciliation_completed' as const,
|
||||
...input,
|
||||
});
|
||||
@@ -140,10 +154,14 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
if (!Array.isArray(selected.domains) || selected.domains.length !== 8) {
|
||||
fail('receipt domain catalog is invalid');
|
||||
}
|
||||
if (selected.schemaVersion !== 1 && selected.schemaVersion !== 2) {
|
||||
fail('receipt schema version is invalid');
|
||||
}
|
||||
const schemaVersion = selected.schemaVersion;
|
||||
const rawDomains = selected.domains as unknown[];
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||
domainEvidence(rawDomains[index], domain),
|
||||
domainEvidence(rawDomains[index], domain, schemaVersion),
|
||||
),
|
||||
);
|
||||
const adapterCount = domains.filter(
|
||||
@@ -153,7 +171,10 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
const normalized = Object.freeze({ ...raw, domains });
|
||||
if (
|
||||
selected.schema !== RECEIPT_SCHEMA ||
|
||||
selected.schemaVersion !== 1 ||
|
||||
(schemaVersion === 2) !==
|
||||
domains.some(
|
||||
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||
) ||
|
||||
selected.state !== 'reconciliation_completed' ||
|
||||
typeof selected.completionId !== 'string' ||
|
||||
!UUID_V4.test(selected.completionId) ||
|
||||
@@ -174,7 +195,7 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
].every(
|
||||
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||
) ||
|
||||
(selected.adapterCount !== 0 && selected.adapterCount !== 1) ||
|
||||
![0, 1, 2].includes(selected.adapterCount as number) ||
|
||||
selected.adapterCount !== adapterCount ||
|
||||
!Number.isSafeInteger(selected.completedAtMs) ||
|
||||
(selected.completedAtMs as number) < 0 ||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type LocalReconciliationDiagnosticReason =
|
||||
| 'reviewable_fact'
|
||||
| 'unknown_schema'
|
||||
| 'secret_custody_required'
|
||||
| 'historical_preservation_required'
|
||||
| 'historical_integrity_required'
|
||||
| 'identity_custody_required';
|
||||
|
||||
@@ -89,7 +90,83 @@ function quotedIdentifier(name: string): string {
|
||||
return `"${name.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
||||
function targetRunHistoryIsTerminal(client: DatabaseSync): boolean {
|
||||
const tables = new Map<string, Set<string>>();
|
||||
for (const tableName of ['Runs', 'RunAttempts', 'StepRuns'] as const) {
|
||||
const table = client
|
||||
.prepare(
|
||||
`SELECT 1 AS present
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table' AND name = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(tableName) as { readonly present?: unknown } | undefined;
|
||||
if (table?.present !== 1) continue;
|
||||
const columns = new Set<string>();
|
||||
const rows = client
|
||||
.prepare(`SELECT name FROM pragma_table_info(?) ORDER BY cid LIMIT 257`)
|
||||
.iterate(tableName) as IterableIterator<{ readonly name?: unknown }>;
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
count += 1;
|
||||
if (
|
||||
count > 256 ||
|
||||
typeof row.name !== 'string' ||
|
||||
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
columns.add(row.name);
|
||||
}
|
||||
tables.set(tableName, columns);
|
||||
}
|
||||
const runs = tables.get('Runs');
|
||||
if (!runs?.has('status') || !runs.has('finished_at_ms')) return false;
|
||||
const activeRun = client
|
||||
.prepare(
|
||||
`SELECT 1 AS present
|
||||
FROM "Runs"
|
||||
WHERE status NOT IN ('succeeded','failed','cancelled','timed_out')
|
||||
OR finished_at_ms IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as { readonly present?: unknown } | undefined;
|
||||
if (activeRun?.present === 1) return false;
|
||||
const attempts = tables.get('RunAttempts');
|
||||
if (attempts !== undefined) {
|
||||
if (!attempts.has('status')) return false;
|
||||
const activeAttempt = client
|
||||
.prepare(
|
||||
`SELECT 1 AS present
|
||||
FROM "RunAttempts"
|
||||
WHERE status IN ('claimed','starting','running')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as { readonly present?: unknown } | undefined;
|
||||
if (activeAttempt?.present === 1) return false;
|
||||
}
|
||||
const steps = tables.get('StepRuns');
|
||||
if (steps !== undefined) {
|
||||
if (!steps.has('status') || !steps.has('finished_at_ms')) return false;
|
||||
const activeStep = client
|
||||
.prepare(
|
||||
`SELECT 1 AS present
|
||||
FROM "StepRuns"
|
||||
WHERE status NOT IN ('succeeded','failed','skipped','cancelled','timed_out')
|
||||
OR finished_at_ms IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as { readonly present?: unknown } | undefined;
|
||||
if (activeStep?.present === 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function requirement(
|
||||
client: DatabaseSync,
|
||||
database: LocalReconciliationSealedDatabaseKind,
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
): Readonly<{
|
||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||
reason: LocalReconciliationDiagnosticReason;
|
||||
}> {
|
||||
@@ -106,6 +183,12 @@ function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
||||
});
|
||||
}
|
||||
if (domain === 'run_history') {
|
||||
if (database === 'legacy' || targetRunHistoryIsTerminal(client)) {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'required' as const,
|
||||
reason: 'historical_preservation_required' as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'blocked' as const,
|
||||
reason: 'historical_integrity_required' as const,
|
||||
@@ -161,8 +244,11 @@ function fact(
|
||||
name: string,
|
||||
tableName: string,
|
||||
count: string | null,
|
||||
review: Readonly<{
|
||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||
reason: LocalReconciliationDiagnosticReason;
|
||||
}>,
|
||||
): Readonly<LocalReconciliationDiagnosticFact> {
|
||||
const review = requirement(domain);
|
||||
const payload = Object.freeze({
|
||||
schema: FACT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
@@ -190,6 +276,7 @@ function schemaFacts(
|
||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||
complete: boolean;
|
||||
}> {
|
||||
const review = requirement(client, database, domain);
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT type, name, tbl_name AS tableName
|
||||
@@ -240,6 +327,7 @@ function schemaFacts(
|
||||
row.name,
|
||||
row.tableName,
|
||||
null,
|
||||
review,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -256,6 +344,7 @@ function tableFacts(
|
||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||
complete: boolean;
|
||||
}> {
|
||||
const review = requirement(client, database, domain);
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT name, type
|
||||
@@ -302,6 +391,7 @@ function tableFacts(
|
||||
row.name,
|
||||
row.name,
|
||||
rowCount(client, domain, row.name, row.type),
|
||||
review,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
preserveLocalReconciliationRunHistory,
|
||||
readLocalReconciliationAutomationDecisionTerminal,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
planLocalReconciliationAutomation,
|
||||
@@ -31,6 +32,7 @@ const {
|
||||
verifyLocalReconciliationCompletion,
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationRunHistory,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
@@ -710,6 +712,50 @@ function automationDatabaseInitializer() {
|
||||
};
|
||||
}
|
||||
|
||||
function runHistoryDatabaseInitializer() {
|
||||
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
|
||||
const legacy = new DatabaseSync(legacySourcePath);
|
||||
legacy.exec(`
|
||||
CREATE TABLE "CrontabStats" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO "CrontabStats" (id, timestamp, status)
|
||||
VALUES (1, 1000, 0);
|
||||
`);
|
||||
legacy.close();
|
||||
fs.chmodSync(legacySourcePath, 0o600);
|
||||
fs.copyFileSync(legacySourcePath, recoveryPath);
|
||||
fs.chmodSync(recoveryPath, 0o600);
|
||||
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec(`
|
||||
CREATE TABLE "Runs" (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
finished_at_ms INTEGER
|
||||
);
|
||||
INSERT INTO "Runs" (id, status, finished_at_ms)
|
||||
VALUES ('baseline', 'succeeded', 1000);
|
||||
`);
|
||||
target.close();
|
||||
fs.chmodSync(targetDatabasePath, 0o600);
|
||||
};
|
||||
}
|
||||
|
||||
function mutateRunHistoryTarget({ targetDatabasePath }, status = 'failed') {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target
|
||||
.prepare(
|
||||
`INSERT INTO "Runs" (id, status, finished_at_ms)
|
||||
VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run('captured', status, status === 'running' ? null : 2000);
|
||||
target.close();
|
||||
return Object.freeze({});
|
||||
}
|
||||
|
||||
function automationReadyDatabaseInitializer() {
|
||||
const initializeLegacy = automationDatabaseInitializer();
|
||||
return (paths) => {
|
||||
@@ -3244,6 +3290,303 @@ test('completion fence retains automation rollback backup while other domains re
|
||||
);
|
||||
});
|
||||
|
||||
test('run history preservation seals terminal histories and completes through v2 evidence', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000501',
|
||||
reviewId: '00000000-0000-4000-8000-000000000502',
|
||||
applicationId: '00000000-0000-4000-8000-000000000503',
|
||||
reviewSuffix: 'run-history-preservation',
|
||||
createDefaultSidecars: false,
|
||||
initializeDatabases: runHistoryDatabaseInitializer(),
|
||||
mutateTarget(paths) {
|
||||
return mutateRunHistoryTarget(paths);
|
||||
},
|
||||
mutateDecisions(records) {
|
||||
const selected = records.filter(
|
||||
(record) =>
|
||||
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||
record.database === 'legacy' &&
|
||||
record.domain === 'run_history',
|
||||
);
|
||||
assert.ok(selected.length > 0);
|
||||
for (const record of selected) {
|
||||
record.disposition = 'retain_both';
|
||||
record.reason = 'preserve_both';
|
||||
}
|
||||
},
|
||||
});
|
||||
const prepared = await prepareLocalReconciliationApplication(
|
||||
state.prepareApplicationCommand,
|
||||
);
|
||||
const application = await commitLocalReconciliationApplication(
|
||||
applicationCommitCommand(state, prepared),
|
||||
);
|
||||
assert.equal(application.outcome, 'adapter_required');
|
||||
const applicationPlan = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(state.applicationRoot, application.applicationId, 'plan.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
applicationPlan.domains.find((domain) => domain.domain === 'run_history')
|
||||
.action,
|
||||
'adapter_required',
|
||||
);
|
||||
|
||||
const runHistoryRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'run-history-preservation-root',
|
||||
);
|
||||
fs.mkdirSync(runHistoryRoot, { mode: 0o700 });
|
||||
const preservationCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.run-history.preserve',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
runHistoryRoot,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
preservationId: '00000000-0000-4000-8000-000000000504',
|
||||
applicationId: application.applicationId,
|
||||
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||
expectedHeadDigest: application.instanceHeadDigest,
|
||||
decisionFilePath: state.reviewFile.filePath,
|
||||
preservedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 3,
|
||||
},
|
||||
};
|
||||
for (const boundary of ['afterReceiptPublished', 'afterTerminalSealed']) {
|
||||
await assert.rejects(
|
||||
preserveLocalReconciliationRunHistory(preservationCommand, {
|
||||
[boundary]() {
|
||||
throw new Error(`run history ${boundary} response loss`);
|
||||
},
|
||||
}),
|
||||
new RegExp(`run history ${boundary} response loss`),
|
||||
);
|
||||
}
|
||||
const preserved = await preserveLocalReconciliationRunHistory(
|
||||
preservationCommand,
|
||||
);
|
||||
assert.equal(preserved.status, 'existing');
|
||||
assert.ok(preserved.legacyFactCount > 0);
|
||||
assert.ok(preserved.targetFactCount > 0);
|
||||
const preservationDirectory = path.join(
|
||||
runHistoryRoot,
|
||||
preservationCommand.request.preservationId,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(preservationDirectory), ['receipt.json']);
|
||||
assert.equal(fs.statSync(preservationDirectory).mode & 0o777, 0o500);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(preservationDirectory, 'receipt.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
const preservationReceiptText = fs.readFileSync(
|
||||
path.join(preservationDirectory, 'receipt.json'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(preservationReceiptText.includes('CrontabStats'), false);
|
||||
assert.equal(preservationReceiptText.includes('captured'), false);
|
||||
assert.equal(
|
||||
preservationReceiptText.includes(state.reviewFile.filePath),
|
||||
false,
|
||||
);
|
||||
|
||||
const preservationVerifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.run-history.verify',
|
||||
options: preservationCommand.options,
|
||||
request: {
|
||||
preservationId: preservationCommand.request.preservationId,
|
||||
applicationId: preservationCommand.request.applicationId,
|
||||
expectedPreservationDigest: preserved.preservationDigest,
|
||||
decisionFilePath: state.reviewFile.filePath,
|
||||
},
|
||||
};
|
||||
const preservationVerified = await verifyLocalReconciliationRunHistory(
|
||||
preservationVerifyCommand,
|
||||
);
|
||||
assert.equal(preservationVerified.status, 'verified');
|
||||
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-run-history',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
const runHistory = {
|
||||
preservationId: preservationCommand.request.preservationId,
|
||||
expectedPreservationDigest: preserved.preservationDigest,
|
||||
};
|
||||
const completionCommand = {
|
||||
schemaVersion: 2,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: null,
|
||||
runHistory: {
|
||||
runHistoryRoot,
|
||||
decisionFilePath: state.reviewFile.filePath,
|
||||
},
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000505',
|
||||
applicationId: application.applicationId,
|
||||
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||
expectedHeadDigest: application.instanceHeadDigest,
|
||||
automation: null,
|
||||
runHistory,
|
||||
completedAtMs: preservationCommand.request.preservedAtMs + 1,
|
||||
},
|
||||
};
|
||||
const completed = await completeLocalReconciliation(completionCommand);
|
||||
assert.equal(completed.adapterCount, 1);
|
||||
const completionReceipt = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
completionRoot,
|
||||
completionCommand.request.completionId,
|
||||
'receipt.json',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(completionReceipt.schemaVersion, 2);
|
||||
assert.deepEqual(
|
||||
completionReceipt.domains.find((domain) => domain.domain === 'run_history'),
|
||||
{
|
||||
domain: 'run_history',
|
||||
action: 'adapter_required',
|
||||
evidenceKind: 'run_history_preservation',
|
||||
evidenceDigest: preserved.preservationDigest,
|
||||
},
|
||||
);
|
||||
const completionVerified = await verifyLocalReconciliationCompletion({
|
||||
schemaVersion: 2,
|
||||
operation: 'local.deployment.reconciliation.complete.verify',
|
||||
options: completionCommand.options,
|
||||
request: {
|
||||
completionId: completionCommand.request.completionId,
|
||||
applicationId: completionCommand.request.applicationId,
|
||||
expectedCompletionDigest: completed.completionDigest,
|
||||
automation: null,
|
||||
runHistory,
|
||||
},
|
||||
});
|
||||
assert.equal(completionVerified.status, 'verified');
|
||||
|
||||
const commandPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'run-history-verify.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
commandPath,
|
||||
`${JSON.stringify(preservationVerifyCommand)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-run-history-verify',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||
assert.equal(cli.stdout.includes(runHistoryRoot), false);
|
||||
assert.equal(cli.stdout.includes('CrontabStats'), false);
|
||||
|
||||
const preservationReceiptPath = path.join(
|
||||
preservationDirectory,
|
||||
'receipt.json',
|
||||
);
|
||||
const tamperedReceipt = JSON.parse(
|
||||
fs.readFileSync(preservationReceiptPath, 'utf8'),
|
||||
);
|
||||
tamperedReceipt.legacyFactCount += 1;
|
||||
fs.chmodSync(preservationDirectory, 0o700);
|
||||
fs.chmodSync(preservationReceiptPath, 0o600);
|
||||
fs.writeFileSync(
|
||||
preservationReceiptPath,
|
||||
`${JSON.stringify(tamperedReceipt, null, 2)}\n`,
|
||||
);
|
||||
fs.chmodSync(preservationReceiptPath, 0o400);
|
||||
fs.chmodSync(preservationDirectory, 0o500);
|
||||
await assert.rejects(
|
||||
verifyLocalReconciliationRunHistory(preservationVerifyCommand),
|
||||
/receipt binding is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('run history preservation rejects active target runs and review escalation', async (t) => {
|
||||
const state = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000511',
|
||||
reviewId: '00000000-0000-4000-8000-000000000512',
|
||||
reviewSuffix: 'run-history-active',
|
||||
createDefaultSidecars: false,
|
||||
initializeDatabases: runHistoryDatabaseInitializer(),
|
||||
mutateTarget(paths) {
|
||||
return mutateRunHistoryTarget(paths, 'running');
|
||||
},
|
||||
});
|
||||
const pageCommand = diagnosticCommand(state, state.prepared, {
|
||||
database: 'target',
|
||||
domain: 'run_history',
|
||||
factKind: 'table',
|
||||
outputName: 'active-run-history.json',
|
||||
});
|
||||
writeLocalReconciliationReviewDiagnostics(pageCommand);
|
||||
const page = JSON.parse(
|
||||
fs.readFileSync(pageCommand.request.outputPath, 'utf8'),
|
||||
);
|
||||
assert.ok(page.records.length > 0);
|
||||
assert.equal(
|
||||
page.records.every(
|
||||
(fact) =>
|
||||
fact.decisionRequirement === 'blocked' &&
|
||||
fact.reason === 'historical_integrity_required',
|
||||
),
|
||||
true,
|
||||
);
|
||||
const selected = state.reviewFile.records.find(
|
||||
(record) =>
|
||||
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||
record.database === 'target' &&
|
||||
record.domain === 'run_history',
|
||||
);
|
||||
assert.ok(selected);
|
||||
selected.disposition = 'retain_target';
|
||||
selected.reason = 'preserve_target';
|
||||
fs.writeFileSync(
|
||||
state.reviewFile.filePath,
|
||||
`${state.reviewFile.records
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(state.command, state.dependencies),
|
||||
/decision disposition is not allowed for canonical fact/,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
state.reviewRoot,
|
||||
state.reviewCommand.request.reviewId,
|
||||
'review.json',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('automation adapter builds a sealed row plan with bounded conflict evidence', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000431',
|
||||
|
||||
Reference in New Issue
Block a user