feat(ql3): plan automation reconciliation rows

This commit is contained in:
whyour
2026-08-22 00:26:04 +08:00
parent c218f71250
commit 1834c00ff7
15 changed files with 2454 additions and 8 deletions
+5
View File
@@ -40,6 +40,11 @@
"require": "./dist/legacy-adoption/legacyCrontabDecisionIssuer.js",
"default": "./dist/legacy-adoption/legacyCrontabDecisionIssuer.js"
},
"./adoption-inspection": {
"types": "./dist/legacy-adoption/legacyCrontabInspection.d.ts",
"require": "./dist/legacy-adoption/legacyCrontabInspection.js",
"default": "./dist/legacy-adoption/legacyCrontabInspection.js"
},
"./package-staging": {
"types": "./dist/plugin-package/pluginPackageStaging.d.ts",
"require": "./dist/plugin-package/pluginPackageStaging.js",
@@ -0,0 +1,2 @@
export type { LegacyCrontabAdoptionInspection } from './legacyCrontabAdoption';
export { visitLegacyCrontabAdoptionInspections } from './legacyCrontabAdoption';
@@ -31,6 +31,7 @@ export type LocalCutoverInstanceHeadState =
| 'reconciliation_reviewed'
| 'reconciliation_application_prepared'
| 'reconciliation_application_planned'
| 'reconciliation_automation_planned'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -170,6 +171,7 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
head.state !== 'reconciliation_reviewed' &&
head.state !== 'reconciliation_application_prepared' &&
head.state !== 'reconciliation_application_planned' &&
head.state !== 'reconciliation_automation_planned' &&
head.state !== 'rollback_prepared' &&
head.state !== 'legacy_restart_requested' &&
head.state !== 'legacy_running' &&
@@ -344,6 +346,7 @@ export function advanceLocalCutoverInstanceHead(
| 'reconciliation_reviewed'
| 'reconciliation_application_prepared'
| 'reconciliation_application_planned'
| 'reconciliation_automation_planned'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -390,6 +393,7 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_reviewed' ||
current.state === 'reconciliation_application_prepared' ||
current.state === 'reconciliation_application_planned' ||
current.state === 'reconciliation_automation_planned' ||
current.state === 'legacy_restart_requested' ||
current.state === 'legacy_running' ||
current.state === 'legacy_ready')
@@ -421,6 +425,8 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_reviewed') ||
(state === 'reconciliation_application_planned' &&
current.state === 'reconciliation_application_prepared') ||
(state === 'reconciliation_automation_planned' &&
current.state === 'reconciliation_application_planned') ||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
(state === 'legacy_restart_requested' &&
current.state === 'rollback_prepared') ||
@@ -131,6 +131,12 @@ import {
verifyLocalReconciliationApplication,
verifyLocalReconciliationApplicationCommandFile,
} from './reconciliation/application/coordinator';
import {
planLocalReconciliationAutomation,
planLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationAutomationPlanCommandFile,
} from './reconciliation/application/automation/coordinator';
export {
commitLocalReconciliationPlan,
@@ -153,6 +159,10 @@ export {
commitLocalReconciliationApplicationCommandFile,
verifyLocalReconciliationApplication,
verifyLocalReconciliationApplicationCommandFile,
planLocalReconciliationAutomation,
planLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationAutomationPlanCommandFile,
};
export {
@@ -182,6 +192,28 @@ export {
type LocalReconciliationApplicationPlan,
type LocalReconciliationApplicationPlanReceipt,
} from './reconciliation/application/plan';
export {
normalizeLocalReconciliationAutomationPlanCommand,
normalizeLocalReconciliationAutomationVerifyCommand,
type LocalReconciliationAutomationOptions,
type LocalReconciliationAutomationPlanCommand,
type LocalReconciliationAutomationPlanResult,
type LocalReconciliationAutomationVerifyCommand,
} from './reconciliation/application/automation/contract';
export {
type LocalReconciliationAutomationPlanDependencies,
} from './reconciliation/application/automation/coordinator';
export {
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
normalizeLocalReconciliationAutomationPlanReceipt,
type LocalReconciliationAutomationPlanFooter,
type LocalReconciliationAutomationPlanHeader,
type LocalReconciliationAutomationPlanReceipt,
type LocalReconciliationAutomationPlanRow,
type LocalReconciliationAutomationPlanSummary,
type LocalReconciliationAutomationRowRequirement,
} from './reconciliation/application/automation/rowPlan';
export {
LocalDeploymentConfigurationError,
@@ -25,6 +25,8 @@ import {
prepareLocalReconciliationApplicationCommandFile,
commitLocalReconciliationApplicationCommandFile,
verifyLocalReconciliationApplicationCommandFile,
planLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationPlanCommandFile,
writeLocalReconciliationReviewDiagnosticsCommandFile,
prepareLocalDeploymentCommandFile,
proveLocalDeploymentLegacyReadinessCommandFile,
@@ -40,7 +42,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|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|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')) {
@@ -82,6 +84,8 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'reconciliation-application-prepare' &&
argv[0] !== 'reconciliation-application-commit' &&
argv[0] !== 'reconciliation-application-verify' &&
argv[0] !== 'reconciliation-automation-plan' &&
argv[0] !== 'reconciliation-automation-verify' &&
argv[0] !== 'compose-revision' &&
argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' &&
@@ -178,6 +182,10 @@ async function main(argv: readonly string[]): Promise<void> {
? commitLocalReconciliationApplicationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-application-verify'
? verifyLocalReconciliationApplicationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-plan'
? planLocalReconciliationAutomationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-verify'
? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!)
: argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight'
@@ -0,0 +1,325 @@
import path from 'node:path';
import { currentIdentity } from '../../../foundation/contract';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[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_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
const MAX_PATH_BYTES = 4_096;
export interface LocalReconciliationAutomationOptions {
readonly deploymentRoot: string;
readonly applicationRoot: string;
readonly automationRoot: string;
readonly allowRootService: boolean;
}
export interface LocalReconciliationAutomationPlanCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.automation.plan';
readonly options: Readonly<LocalReconciliationAutomationOptions>;
readonly request: Readonly<{
automationId: string;
applicationId: string;
expectedApplicationPlanDigest: string;
expectedHeadDigest: string;
decisionFilePath: string;
projectId: string;
legacyTimezone: string | null;
preparedAtMs: number;
}>;
}
export interface LocalReconciliationAutomationVerifyCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.automation.verify';
readonly options: Readonly<LocalReconciliationAutomationOptions>;
readonly request: Readonly<{
automationId: string;
expectedAutomationPlanDigest: string;
}>;
}
export interface LocalReconciliationAutomationPlanResult {
readonly schemaVersion: 1;
readonly operation:
| 'local.deployment.reconciliation.automation.plan'
| 'local.deployment.reconciliation.automation.verify';
readonly status: 'prepared' | 'existing' | 'verified';
readonly state: 'reconciliation_automation_planned';
readonly automationId: string;
readonly automationPlanDigest: string;
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
readonly rowCount: number;
readonly eligibleCount: number;
readonly conflictCount: number;
readonly instanceHeadDigest: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation ${message}`,
);
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${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])
) {
configurationError(`${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_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${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 digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
configurationError(`${label} must be a SHA-256 digest`);
}
return value;
}
function identifier(value: unknown, label: string): string {
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
configurationError(`${label} must be a lowercase UUID v4`);
}
return value;
}
function projectId(value: unknown): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\u0000-\u001f\u007f]/u.test(value)
) {
configurationError('projectId is invalid');
}
return value;
}
function timezone(value: unknown): string | null {
if (value === null) return null;
if (
typeof value !== 'string' ||
value.length < 1 ||
Buffer.byteLength(value, 'utf8') > 128 ||
value.includes('\0')
) {
configurationError('legacyTimezone is invalid');
}
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: value,
}).resolvedOptions().timeZone;
} catch {
return configurationError('legacyTimezone is unsupported');
}
}
function normalizeOptions(
value: unknown,
): Readonly<LocalReconciliationAutomationOptions> {
const options = object(value, 'options');
exact(
options,
[
'allowRootService',
'applicationRoot',
'automationRoot',
'deploymentRoot',
],
'options',
);
const identity = currentIdentity();
if (
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService
) {
configurationError('command identity is invalid');
}
const roots = [
safePath(options.deploymentRoot, 'deploymentRoot'),
safePath(options.applicationRoot, 'applicationRoot'),
safePath(options.automationRoot, 'automationRoot'),
];
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]!)
) {
configurationError('deployment, application and automation roots overlap');
}
}
}
return Object.freeze({
deploymentRoot: roots[0]!,
applicationRoot: roots[1]!,
automationRoot: roots[2]!,
allowRootService: options.allowRootService,
});
}
function command(
value: unknown,
operation:
| LocalReconciliationAutomationPlanCommand['operation']
| LocalReconciliationAutomationVerifyCommand['operation'],
): Readonly<{
options: Readonly<LocalReconciliationAutomationOptions>;
request: Record<string, unknown>;
}> {
const selected = object(value, 'command');
exact(
selected,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
configurationError('command version or operation is invalid');
}
return Object.freeze({
options: normalizeOptions(selected.options),
request: object(selected.request, 'request'),
});
}
export function normalizeLocalReconciliationAutomationPlanCommand(
value: unknown,
): Readonly<LocalReconciliationAutomationPlanCommand> {
const selected = command(
value,
'local.deployment.reconciliation.automation.plan',
);
exact(
selected.request,
[
'applicationId',
'automationId',
'decisionFilePath',
'expectedApplicationPlanDigest',
'expectedHeadDigest',
'legacyTimezone',
'preparedAtMs',
'projectId',
],
'request',
);
const decisionFilePath = safePath(
selected.request.decisionFilePath,
'decisionFilePath',
);
if (
[
selected.options.deploymentRoot,
selected.options.applicationRoot,
selected.options.automationRoot,
].some(
(root) =>
overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root),
)
) {
configurationError('decisionFilePath overlaps an authority root');
}
if (
!Number.isSafeInteger(selected.request.preparedAtMs) ||
(selected.request.preparedAtMs as number) < 0
) {
configurationError('preparedAtMs is invalid');
}
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.plan',
options: selected.options,
request: Object.freeze({
automationId: identifier(selected.request.automationId, 'automationId'),
applicationId: identifier(
selected.request.applicationId,
'applicationId',
),
expectedApplicationPlanDigest: digest(
selected.request.expectedApplicationPlanDigest,
'expectedApplicationPlanDigest',
),
expectedHeadDigest: digest(
selected.request.expectedHeadDigest,
'expectedHeadDigest',
),
decisionFilePath,
projectId: projectId(selected.request.projectId),
legacyTimezone: timezone(selected.request.legacyTimezone),
preparedAtMs: selected.request.preparedAtMs as number,
}),
});
}
export function normalizeLocalReconciliationAutomationVerifyCommand(
value: unknown,
): Readonly<LocalReconciliationAutomationVerifyCommand> {
const selected = command(
value,
'local.deployment.reconciliation.automation.verify',
);
exact(
selected.request,
['automationId', 'expectedAutomationPlanDigest'],
'request',
);
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.verify',
options: selected.options,
request: Object.freeze({
automationId: identifier(selected.request.automationId, 'automationId'),
expectedAutomationPlanDigest: digest(
selected.request.expectedAutomationPlanDigest,
'expectedAutomationPlanDigest',
),
}),
});
}
@@ -0,0 +1,792 @@
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 {
ensurePrivateDirectory,
publishExactFile,
syncPublishedDirectory,
validatePrivateDirectory,
} from '../../../foundation/files';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
type LocalCutoverInstanceHead,
} from '../../../cutover/instanceLineage';
import { readLocalReconciliationPlanTerminal } from '../../planning/preparation';
import {
assertLocalReconciliationReviewDecisionMatchesFact,
withLocalReconciliationReviewDecisionFile,
type LocalReconciliationReviewDisposition,
} from '../../review/decisionFile';
import { visitLocalReconciliationDiagnosticFacts } from '../../review/diagnostics';
import { withLocalReconciliationSealedDatabase } from '../../sealed-bundle/reader';
import {
readLocalReconciliationApplicationTerminal,
type LocalReconciliationApplicationTerminal,
} from '../coordinator';
import {
normalizeLocalReconciliationAutomationPlanCommand,
normalizeLocalReconciliationAutomationVerifyCommand,
type LocalReconciliationAutomationPlanCommand,
type LocalReconciliationAutomationPlanResult,
type LocalReconciliationAutomationVerifyCommand,
} from './contract';
import {
buildLocalReconciliationAutomationPlanReceipt,
hashLocalReconciliationAutomationPlanFile,
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
normalizeLocalReconciliationAutomationPlanReceipt,
writeLocalReconciliationAutomationPlan,
type LocalReconciliationAutomationPlanHeader,
type LocalReconciliationAutomationPlanReceipt,
} from './rowPlan';
const MAX_RECEIPT_BYTES = 64 * 1024;
export interface LocalReconciliationAutomationPlanDependencies {
readonly beforeDatabaseOpen?: (
kind: 'legacy' | 'target',
mode: 'main_only_immutable' | 'wal_shm_readonly',
cacheKiB: 2_048 | 8_192,
) => void;
readonly afterDatabaseClose?: (kind: 'legacy' | 'target') => void;
readonly afterPlanPublished?: () => void;
readonly afterReceiptPublished?: () => void;
readonly afterTerminalSealed?: () => void;
readonly afterHeadAdvanced?: () => void;
}
interface AutomationPaths {
readonly root: string;
readonly staging: string;
readonly plan: string;
readonly planStage: string;
readonly receipt: string;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation ${message}`,
{ cause },
);
}
function automationPaths(
automationRoot: string,
automationId: string,
): Readonly<AutomationPaths> {
const root = path.join(automationRoot, automationId);
const staging = path.join(root, 'staging');
return Object.freeze({
root,
staging,
plan: path.join(root, 'plan.ndjson'),
planStage: path.join(staging, 'plan.ndjson.stage'),
receipt: path.join(root, 'receipt.json'),
});
}
function validateCatalog(selected: Readonly<AutomationPaths>, terminal: boolean): void {
const allowed = new Set([
'plan.ndjson',
'receipt.json',
'staging',
...(!terminal ? ['.receipt.json.ql3-deploy-stage'] : []),
]);
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
configurationError('plan root contains unknown material');
}
}
const stagingEntries = fs.readdirSync(selected.staging);
if (
terminal
? stagingEntries.length !== 0
: stagingEntries.some((entry) => entry !== 'plan.ndjson.stage')
) {
configurationError('plan staging contains unknown material');
}
}
function validateDirectory(
directory: string,
uid: number,
modes: readonly number[],
label: string,
): number {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
return configurationError(`${label} is unavailable`, error);
}
const mode = stat.mode & 0o777;
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
!modes.includes(mode) ||
fs.realpathSync(directory) !== directory
) {
configurationError(`${label} identity is invalid`);
}
return mode;
}
function readReceipt(
filePath: string,
uid: number,
allowedModes: readonly number[],
): Readonly<LocalReconciliationAutomationPlanReceipt> {
let descriptor: number | undefined;
let bytes: Buffer | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
!allowedModes.includes(Number(before.mode) & 0o777) ||
before.nlink !== 1n ||
before.size < 2n ||
before.size > BigInt(MAX_RECEIPT_BYTES)
) {
configurationError('receipt identity is invalid');
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size
) {
configurationError('receipt changed while opening');
}
bytes = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const current = fs.lstatSync(filePath, { bigint: true });
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
current.dev !== before.dev ||
current.ino !== before.ino ||
current.mtimeNs !== before.mtimeNs ||
current.ctimeNs !== before.ctimeNs ||
current.mode !== before.mode ||
current.nlink !== before.nlink
) {
configurationError('receipt changed while reading');
}
return normalizeLocalReconciliationAutomationPlanReceipt(
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)),
);
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError('receipt cannot be read', error);
} finally {
bytes?.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function validatePlanFile(
filePath: string,
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
uid: number,
allowedModes: readonly number[],
): void {
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
!allowedModes.includes(Number(before.mode) & 0o777) ||
before.nlink !== 1n ||
before.size !== BigInt(receipt.planFileBytes)
) {
configurationError('plan file identity is invalid');
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size ||
hashLocalReconciliationAutomationPlanFile(
descriptor,
receipt.planFileBytes,
) !== receipt.planFileDigest
) {
configurationError('plan file content drifted');
}
const after = fs.fstatSync(descriptor, { bigint: true });
const current = fs.lstatSync(filePath, { bigint: true });
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.mtimeNs !== before.mtimeNs ||
after.ctimeNs !== before.ctimeNs ||
current.dev !== before.dev ||
current.ino !== before.ino ||
current.mtimeNs !== before.mtimeNs ||
current.ctimeNs !== before.ctimeNs
) {
configurationError('plan file changed while verifying');
}
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
configurationError('plan file cannot be verified', error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function receiptContents(
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
): string {
const contents = `${JSON.stringify(receipt, null, 2)}\n`;
if (Buffer.byteLength(contents, 'utf8') > MAX_RECEIPT_BYTES) {
configurationError('receipt exceeds 64 KiB');
}
return contents;
}
function validateApplicationBinding(
command: Readonly<LocalReconciliationAutomationPlanCommand>,
terminal: Readonly<LocalReconciliationApplicationTerminal>,
): void {
if (
terminal.intent.command.options.deploymentRoot !==
command.options.deploymentRoot ||
terminal.intent.command.options.applicationRoot !==
command.options.applicationRoot ||
terminal.plan.applicationId !== command.request.applicationId ||
terminal.plan.applicationPlanDigest !==
command.request.expectedApplicationPlanDigest ||
terminal.head.state !== 'reconciliation_application_planned' ||
terminal.head.headDigest !== command.request.expectedHeadDigest ||
terminal.head.sourceRecordDigest !== terminal.plan.applicationPlanDigest ||
command.request.preparedAtMs < terminal.plan.committedAtMs
) {
configurationError('plan is detached from its exact application head');
}
const automation = terminal.plan.domains.find(
(selected) => selected.domain === 'automation',
);
if (
!automation ||
(automation.action !== 'adapter_required' &&
automation.action !== 'adapter_and_manual')
) {
configurationError('application plan does not require an automation adapter');
}
}
function tableDisposition(
command: Readonly<LocalReconciliationAutomationPlanCommand>,
terminal: Readonly<LocalReconciliationApplicationTerminal>,
dependencies: LocalReconciliationAutomationPlanDependencies,
uid: number,
): Readonly<{
disposition: 'adopt_legacy' | 'retain_both';
decisionFileDigest: string;
confirmDecisionFileIdentity(): void;
planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
}> {
const planTerminal = readLocalReconciliationPlanTerminal(
terminal.review.intent.command.options.planRoot,
terminal.review.intent.command.request.planId,
uid,
);
let selectedDisposition: LocalReconciliationReviewDisposition | undefined;
const reviewed = withLocalReconciliationReviewDecisionFile(
command.request.decisionFilePath,
{
reviewId: terminal.review.review.reviewId,
profile: terminal.plan.profile,
planDigest: planTerminal.plan.planDigest,
preparationDigest: terminal.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) {
configurationError('decision file omitted a canonical fact');
}
assertLocalReconciliationReviewDecisionMatchesFact(
decision,
fact,
);
if (
fact.database === 'legacy' &&
fact.domain === 'automation' &&
fact.factKind === 'table' &&
fact.tableName === 'Crontabs'
) {
if (selectedDisposition !== undefined) {
configurationError('legacy Crontabs authority is ambiguous');
}
selectedDisposition = decision.disposition;
}
},
),
);
if (opened === null) {
configurationError('manual-required SQLite topology cannot be adapted');
}
}
},
);
if (
reviewed.evidence.fileDigest !==
terminal.review.authorization.decisionFileDigest ||
reviewed.evidence.decisionCount !==
terminal.review.authorization.decisionCount ||
(selectedDisposition !== 'adopt_legacy' &&
selectedDisposition !== 'retain_both')
) {
configurationError('signed review did not authorize legacy Crontabs adoption');
}
return Object.freeze({
disposition: selectedDisposition,
decisionFileDigest: reviewed.evidence.fileDigest,
confirmDecisionFileIdentity: reviewed.confirmIdentity,
planTerminal,
});
}
function maxPlanBytes(profile: 'edge' | 'standalone'): number {
return profile === 'edge'
? MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES
: MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES;
}
function publishPlan(
selected: Readonly<AutomationPaths>,
command: Readonly<LocalReconciliationAutomationPlanCommand>,
terminal: Readonly<LocalReconciliationApplicationTerminal>,
authority: ReturnType<typeof tableDisposition>,
dependencies: LocalReconciliationAutomationPlanDependencies,
uid: number,
): Readonly<LocalReconciliationAutomationPlanReceipt> {
let descriptor: number | undefined;
let createdStage = false;
try {
descriptor = fs.openSync(
selected.planStage,
fs.constants.O_CREAT |
fs.constants.O_EXCL |
fs.constants.O_WRONLY |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
createdStage = true;
fs.fchmodSync(descriptor, 0o600);
const header: Omit<LocalReconciliationAutomationPlanHeader, 'headerDigest'> =
Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-local-reconciliation-automation-plan-header',
automationId: command.request.automationId,
applicationId: command.request.applicationId,
applicationPlanDigest: terminal.plan.applicationPlanDigest,
reviewDigest: terminal.review.review.reviewDigest,
reviewAuthorizationDigest:
terminal.review.authorization.authorizationDigest,
reviewDecisionSetDigest:
terminal.review.authorization.decisionSetDigest,
reviewDecisionFileDigest: authority.decisionFileDigest,
bundleDigest: authority.planTerminal.bundle.receipt.bundleDigest,
bundleFingerprintDigest: authority.planTerminal.bundle.fingerprintDigest,
profile: terminal.plan.profile,
projectId: command.request.projectId,
legacyTimezone: command.request.legacyTimezone,
tableDisposition: authority.disposition,
preparedHeadDigest: terminal.head.headDigest,
preparedAtMs: command.request.preparedAtMs,
});
const generated = withLocalReconciliationSealedDatabase(
authority.planTerminal.bundle,
'target',
uid,
dependencies,
(target) =>
withLocalReconciliationSealedDatabase(
authority.planTerminal.bundle,
'legacy',
uid,
dependencies,
(legacy) =>
writeLocalReconciliationAutomationPlan({
descriptor: descriptor!,
maxBytes: maxPlanBytes(terminal.plan.profile),
header,
legacy,
target,
}),
),
);
if (generated === null || generated === undefined) {
configurationError('manual-required SQLite topology cannot be planned');
}
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
authority.confirmDecisionFileIdentity();
const receipt = buildLocalReconciliationAutomationPlanReceipt(
generated.header,
generated.footer,
generated.fileBytes,
generated.fileDigest,
);
if (fs.existsSync(selected.plan)) {
validatePlanFile(selected.plan, receipt, uid, [0o600]);
} else {
try {
fs.linkSync(selected.planStage, selected.plan);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
validatePlanFile(selected.plan, receipt, uid, [0o600]);
}
syncPublishedDirectory(selected.root);
}
fs.unlinkSync(selected.planStage);
createdStage = false;
syncPublishedDirectory(selected.staging);
return receipt;
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError('plan cannot be published', error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
if (createdStage) {
try {
fs.unlinkSync(selected.planStage);
} catch {
// A complete stage remains recoverable; partial stages fail closed.
}
}
}
}
function sealFile(filePath: string, uid: number): void {
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
![0o600, 0o400].includes(Number(before.mode) & 0o777) ||
before.nlink !== 1n
) {
configurationError('terminal file cannot be sealed');
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size
) {
configurationError('terminal file changed while sealing');
}
if ((Number(opened.mode) & 0o777) !== 0o400) {
fs.fchmodSync(descriptor, 0o400);
}
fs.fsyncSync(descriptor);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function sealDirectory(directory: string, uid: number): void {
const mode = validateDirectory(directory, uid, [0o700, 0o500], 'terminal directory');
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
try {
if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function sealTerminal(selected: Readonly<AutomationPaths>, uid: number): void {
if (fs.readdirSync(selected.staging).length !== 0) {
configurationError('staging must be empty before terminal seal');
}
sealFile(selected.plan, uid);
sealFile(selected.receipt, uid);
sealDirectory(selected.staging, uid);
sealDirectory(selected.root, uid);
validateCatalog(selected, true);
}
function advanceHead(
terminal: Readonly<LocalReconciliationApplicationTerminal>,
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
uid: number,
): Readonly<LocalCutoverInstanceHead> {
return advanceLocalCutoverInstanceHead(
{
options: {
deploymentRoot: terminal.intent.command.options.deploymentRoot,
},
request: {
cutoverId: terminal.intent.cutoverId,
profile: terminal.intent.profile,
instanceId: terminal.intent.instanceId,
expectedActivationDigest: terminal.intent.activationDigest,
requestedAtMs: receipt.preparedAtMs,
},
},
uid,
'reconciliation_automation_planned',
terminal.intent.generation,
receipt.automationPlanDigest,
);
}
function result(
operation: LocalReconciliationAutomationPlanResult['operation'],
status: LocalReconciliationAutomationPlanResult['status'],
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
head: Readonly<LocalCutoverInstanceHead>,
): Readonly<LocalReconciliationAutomationPlanResult> {
return Object.freeze({
schemaVersion: 1,
operation,
status,
state: 'reconciliation_automation_planned',
automationId: receipt.automationId,
automationPlanDigest: receipt.automationPlanDigest,
outcome: receipt.outcome,
rowCount: receipt.rowCount,
eligibleCount: receipt.eligibleCount,
conflictCount: receipt.conflictCount,
instanceHeadDigest: head.headDigest,
});
}
function validateTerminalBinding(
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
terminal: Readonly<LocalReconciliationApplicationTerminal>,
automationId: string,
): void {
if (
receipt.automationId !== automationId ||
receipt.applicationId !== terminal.plan.applicationId ||
receipt.applicationPlanDigest !== terminal.plan.applicationPlanDigest
) {
configurationError('terminal plan binding drifted');
}
}
export async function planLocalReconciliationAutomation(
value: unknown,
dependencies: LocalReconciliationAutomationPlanDependencies = {},
): Promise<Readonly<LocalReconciliationAutomationPlanResult>> {
const command = normalizeLocalReconciliationAutomationPlanCommand(value);
const identity = currentIdentity();
for (const [directory, label] of [
[command.options.deploymentRoot, 'deploymentRoot'],
[command.options.applicationRoot, 'applicationRoot'],
[command.options.automationRoot, 'automationRoot'],
] as const) {
validatePrivateDirectory(directory, identity.uid, label);
}
const terminal = await readLocalReconciliationApplicationTerminal(
command.options.applicationRoot,
command.request.applicationId,
identity.uid,
);
const selected = automationPaths(
command.options.automationRoot,
command.request.automationId,
);
if (fs.existsSync(selected.receipt)) {
validateDirectory(
selected.root,
identity.uid,
[0o700, 0o500],
'automation plan root',
);
validateDirectory(
selected.staging,
identity.uid,
[0o700, 0o500],
'automation staging',
);
validateCatalog(selected, false);
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
validateTerminalBinding(receipt, terminal, command.request.automationId);
if (
receipt.applicationPlanDigest !==
command.request.expectedApplicationPlanDigest ||
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
receipt.preparedAtMs !== command.request.preparedAtMs
) {
configurationError('terminal plan is not an exact command replay');
}
validatePlanFile(selected.plan, receipt, identity.uid, [0o600, 0o400]);
let head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
terminal.intent.instanceId,
identity.uid,
);
const existing = head.state === 'reconciliation_automation_planned';
if (
!existing &&
(head.state !== 'reconciliation_application_planned' ||
head.headDigest !== command.request.expectedHeadDigest ||
head.sourceRecordDigest !== terminal.plan.applicationPlanDigest)
) {
configurationError('terminal plan lost instance head binding');
}
if (
existing &&
head.sourceRecordDigest !== receipt.automationPlanDigest
) {
configurationError('terminal plan head digest drifted');
}
sealTerminal(selected, identity.uid);
dependencies.afterTerminalSealed?.();
head = existing ? head : advanceHead(terminal, receipt, identity.uid);
dependencies.afterHeadAdvanced?.();
return result(
command.operation,
existing ? 'existing' : 'prepared',
receipt,
head,
);
}
validateApplicationBinding(command, terminal);
const authority = tableDisposition(
command,
terminal,
dependencies,
identity.uid,
);
ensurePrivateDirectory(selected.root, identity.uid, 'automationPlanDirectory');
ensurePrivateDirectory(
selected.staging,
identity.uid,
'automationPlanStaging',
);
validateCatalog(selected, false);
const receipt = publishPlan(
selected,
command,
terminal,
authority,
dependencies,
identity.uid,
);
dependencies.afterPlanPublished?.();
authority.confirmDecisionFileIdentity();
publishExactFile(
selected.receipt,
receiptContents(receipt),
0o600,
identity.uid,
'reconciliation automation receipt',
);
dependencies.afterReceiptPublished?.();
sealTerminal(selected, identity.uid);
dependencies.afterTerminalSealed?.();
const head = advanceHead(terminal, receipt, identity.uid);
dependencies.afterHeadAdvanced?.();
return result(command.operation, 'prepared', receipt, head);
}
export async function verifyLocalReconciliationAutomationPlan(
value: unknown,
): Promise<Readonly<LocalReconciliationAutomationPlanResult>> {
const command = normalizeLocalReconciliationAutomationVerifyCommand(value);
const identity = currentIdentity();
for (const [directory, label] of [
[command.options.deploymentRoot, 'deploymentRoot'],
[command.options.applicationRoot, 'applicationRoot'],
[command.options.automationRoot, 'automationRoot'],
] as const) {
validatePrivateDirectory(directory, identity.uid, label);
}
const selected = automationPaths(
command.options.automationRoot,
command.request.automationId,
);
validateDirectory(selected.root, identity.uid, [0o500], 'automation plan root');
validateDirectory(selected.staging, identity.uid, [0o500], 'automation staging');
validateCatalog(selected, true);
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
if (
receipt.automationPlanDigest !==
command.request.expectedAutomationPlanDigest
) {
configurationError('expected automation plan digest drifted');
}
const terminal = await readLocalReconciliationApplicationTerminal(
command.options.applicationRoot,
receipt.applicationId,
identity.uid,
);
validateTerminalBinding(receipt, terminal, command.request.automationId);
validatePlanFile(selected.plan, receipt, identity.uid, [0o400]);
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
terminal.intent.instanceId,
identity.uid,
);
if (
head.state !== 'reconciliation_automation_planned' ||
head.sourceRecordDigest !== receipt.automationPlanDigest
) {
configurationError('automation plan is detached from the instance head');
}
return result(command.operation, 'verified', receipt, head);
}
export function planLocalReconciliationAutomationCommandFile(
filePath: string,
dependencies: LocalReconciliationAutomationPlanDependencies = {},
): Promise<Readonly<LocalReconciliationAutomationPlanResult>> {
return planLocalReconciliationAutomation(
readPrivateLocalCommandFile(filePath),
dependencies,
);
}
export function verifyLocalReconciliationAutomationPlanCommandFile(
filePath: string,
): Promise<Readonly<LocalReconciliationAutomationPlanResult>> {
return verifyLocalReconciliationAutomationPlan(
readPrivateLocalCommandFile(filePath),
);
}
@@ -0,0 +1,475 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import type { DatabaseSync } from 'node:sqlite';
import {
visitLegacyCrontabAdoptionInspections,
type LegacyCrontabAdoptionInspection,
} from '@qinglong/local-admin/adoption-inspection';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
import { cutoverDigest } from '../../../cutover/targetEvidence';
const HEADER_KIND = 'qinglong3-local-reconciliation-automation-plan-header';
const ROW_KIND = 'qinglong3-local-reconciliation-automation-plan-row';
const FOOTER_KIND = 'qinglong3-local-reconciliation-automation-plan-footer';
const RECEIPT_SCHEMA =
'qinglong3-local-reconciliation-automation-plan-receipt';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const MAX_LINE_BYTES = 64 * 1024;
const HASH_BUFFER_BYTES = 64 * 1024;
export const MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES =
8 * 1024 * 1024;
export const MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES =
32 * 1024 * 1024;
export type LocalReconciliationAutomationRowRequirement =
| 'review_adopt'
| 'review_skip_conflict'
| 'manual_required';
export interface LocalReconciliationAutomationPlanHeader {
readonly schemaVersion: 1;
readonly kind: typeof HEADER_KIND;
readonly automationId: string;
readonly applicationId: string;
readonly applicationPlanDigest: string;
readonly reviewDigest: string;
readonly reviewAuthorizationDigest: string;
readonly reviewDecisionSetDigest: string;
readonly reviewDecisionFileDigest: string;
readonly bundleDigest: string;
readonly bundleFingerprintDigest: string;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly legacyTimezone: string | null;
readonly tableDisposition: 'adopt_legacy' | 'retain_both';
readonly preparedHeadDigest: string;
readonly preparedAtMs: number;
readonly headerDigest: string;
}
export interface LocalReconciliationAutomationPlanRow {
readonly schemaVersion: 1;
readonly kind: typeof ROW_KIND;
readonly rowOrdinal: number;
readonly sourceDigest: string;
readonly classification:
| 'lossless'
| 'requires_shell_compatibility'
| 'requires_manual_action'
| 'malformed';
readonly reasons: readonly string[];
readonly proposedTaskId: string | null;
readonly enabled: boolean | null;
readonly triggerCount: number;
readonly candidateDigest: string | null;
readonly target:
| Readonly<{ state: 'absent' }>
| Readonly<{
state: 'occupied';
revision: number;
contentDigest: string;
}>;
readonly requirement: LocalReconciliationAutomationRowRequirement;
readonly rowPlanDigest: string;
}
export interface LocalReconciliationAutomationPlanSummary {
readonly rowCount: number;
readonly eligibleCount: number;
readonly shellCompatibilityCount: number;
readonly manualCount: number;
readonly conflictCount: number;
readonly triggerCount: number;
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
}
export interface LocalReconciliationAutomationPlanFooter
extends LocalReconciliationAutomationPlanSummary {
readonly schemaVersion: 1;
readonly kind: typeof FOOTER_KIND;
readonly automationId: string;
readonly legacyInventoryDigest: string;
readonly rowSetDigest: string;
readonly automationPlanDigest: string;
}
export interface LocalReconciliationAutomationPlanReceipt
extends LocalReconciliationAutomationPlanSummary {
readonly schema: typeof RECEIPT_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_automation_planned';
readonly automationId: string;
readonly applicationId: string;
readonly applicationPlanDigest: string;
readonly preparedHeadDigest: string;
readonly legacyInventoryDigest: string;
readonly rowSetDigest: string;
readonly automationPlanDigest: string;
readonly planFileBytes: number;
readonly planFileDigest: string;
readonly preparedAtMs: number;
readonly receiptDigest: string;
}
export interface WriteLocalReconciliationAutomationPlanOptions {
readonly descriptor: number;
readonly maxBytes: number;
readonly header: Omit<LocalReconciliationAutomationPlanHeader, 'headerDigest'>;
readonly legacy: DatabaseSync;
readonly target: DatabaseSync;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation row plan ${message}`,
{ cause },
);
}
function exact(
value: unknown,
keys: readonly string[],
label: string,
): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
configurationError(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
return record;
}
function line(value: unknown): Buffer {
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
if (bytes.byteLength < 3 || bytes.byteLength > MAX_LINE_BYTES + 1) {
bytes.fill(0);
configurationError('record exceeds its line bound');
}
return bytes;
}
function writeAll(descriptor: number, bytes: Buffer): void {
let offset = 0;
while (offset < bytes.byteLength) {
const written = fs.writeSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
);
if (written < 1) configurationError('write stalled');
offset += written;
}
}
function targetTask(
target: DatabaseSync,
projectId: string,
taskId: string,
):
| Readonly<{ state: 'absent' }>
| Readonly<{ state: 'occupied'; revision: number; contentDigest: string }> {
const row = target
.prepare(
`SELECT d."current_revision" AS revision,
r."content_digest" AS contentDigest
FROM "QingLong3TaskDefinitions" d
JOIN "QingLong3TaskDefinitionRevisions" r
ON r."project_id" = d."project_id"
AND r."task_id" = d."task_id"
AND r."revision" = d."current_revision"
WHERE d."project_id" = ? AND d."task_id" = ? LIMIT 1`,
)
.get(projectId, taskId) as
| { readonly revision?: unknown; readonly contentDigest?: unknown }
| undefined;
if (!row) return Object.freeze({ state: 'absent' as const });
if (
!Number.isSafeInteger(row.revision) ||
(row.revision as number) < 1 ||
typeof row.contentDigest !== 'string' ||
!DIGEST_PATTERN.test(row.contentDigest)
) {
configurationError('target task projection drifted');
}
return Object.freeze({
state: 'occupied' as const,
revision: row.revision as number,
contentDigest: row.contentDigest,
});
}
function row(
inspection: Readonly<LegacyCrontabAdoptionInspection>,
target: DatabaseSync,
projectId: string,
): Readonly<LocalReconciliationAutomationPlanRow> {
const diagnostic = inspection.diagnostic;
const selectedTarget =
diagnostic.taskId === null
? Object.freeze({ state: 'absent' as const })
: targetTask(target, projectId, diagnostic.taskId);
const candidateDigest = inspection.candidate
? cutoverDigest({
projectId,
sourceDigest: inspection.candidate.sourceDigest,
task: inspection.candidate.task,
triggers: inspection.candidate.triggers,
})
: null;
const requirement: LocalReconciliationAutomationRowRequirement =
!inspection.candidate
? 'manual_required'
: selectedTarget.state === 'occupied'
? 'review_skip_conflict'
: 'review_adopt';
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: ROW_KIND,
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
classification: diagnostic.classification,
reasons: diagnostic.reasons,
proposedTaskId: diagnostic.taskId,
enabled: diagnostic.enabled,
triggerCount: diagnostic.triggerCount,
candidateDigest,
target: selectedTarget,
requirement,
});
return Object.freeze({ ...payload, rowPlanDigest: cutoverDigest(payload) });
}
export function writeLocalReconciliationAutomationPlan(
options: Readonly<WriteLocalReconciliationAutomationPlanOptions>,
): Readonly<{
header: Readonly<LocalReconciliationAutomationPlanHeader>;
footer: Readonly<LocalReconciliationAutomationPlanFooter>;
fileBytes: number;
fileDigest: string;
}> {
if (
!Number.isSafeInteger(options.maxBytes) ||
options.maxBytes < MAX_LINE_BYTES
) {
configurationError('byte budget is invalid');
}
const header = Object.freeze({
...options.header,
headerDigest: cutoverDigest(options.header),
});
const fileHash = createHash('sha256');
const rowHash = createHash('sha256').update(
'qinglong3.local-reconciliation-automation-row-set.v1\0',
);
let fileBytes = 0;
const append = (value: unknown, includeInRows = false): void => {
const bytes = line(value);
try {
if (fileBytes + bytes.byteLength > options.maxBytes) {
configurationError('exceeds profile byte budget');
}
writeAll(options.descriptor, bytes);
fileHash.update(bytes);
if (includeInRows) rowHash.update(bytes);
fileBytes += bytes.byteLength;
} finally {
bytes.fill(0);
}
};
append(header);
let eligibleCount = 0;
let shellCompatibilityCount = 0;
let manualCount = 0;
let conflictCount = 0;
let triggerCount = 0;
const inventory = visitLegacyCrontabAdoptionInspections(
options.legacy,
header.legacyTimezone,
(inspection) => {
const selected = row(inspection, options.target, header.projectId);
if (selected.requirement === 'review_adopt') eligibleCount += 1;
if (selected.classification === 'requires_shell_compatibility') {
shellCompatibilityCount += 1;
}
if (selected.requirement === 'manual_required') manualCount += 1;
if (selected.requirement === 'review_skip_conflict') conflictCount += 1;
triggerCount += selected.triggerCount;
append(selected, true);
},
);
const summary: LocalReconciliationAutomationPlanSummary = Object.freeze({
rowCount: inventory.rowCount,
eligibleCount,
shellCompatibilityCount,
manualCount,
conflictCount,
triggerCount,
outcome:
inventory.rowCount === 0
? ('no_effect' as const)
: manualCount > 0 || conflictCount > 0
? ('manual_required' as const)
: ('ready' as const),
});
const footerPayload = Object.freeze({
schemaVersion: 1 as const,
kind: FOOTER_KIND,
automationId: header.automationId,
...summary,
legacyInventoryDigest: inventory.inventoryDigest,
rowSetDigest: rowHash.digest('hex'),
});
const footer = Object.freeze({
...footerPayload,
automationPlanDigest: cutoverDigest({
headerDigest: header.headerDigest,
...footerPayload,
}),
});
append(footer);
return Object.freeze({
header,
footer,
fileBytes,
fileDigest: fileHash.digest('hex'),
});
}
export function buildLocalReconciliationAutomationPlanReceipt(
header: Readonly<LocalReconciliationAutomationPlanHeader>,
footer: Readonly<LocalReconciliationAutomationPlanFooter>,
planFileBytes: number,
planFileDigest: string,
): Readonly<LocalReconciliationAutomationPlanReceipt> {
const payload = Object.freeze({
schema: RECEIPT_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_automation_planned' as const,
automationId: header.automationId,
applicationId: header.applicationId,
applicationPlanDigest: header.applicationPlanDigest,
preparedHeadDigest: header.preparedHeadDigest,
legacyInventoryDigest: footer.legacyInventoryDigest,
rowSetDigest: footer.rowSetDigest,
automationPlanDigest: footer.automationPlanDigest,
planFileBytes,
planFileDigest,
rowCount: footer.rowCount,
eligibleCount: footer.eligibleCount,
shellCompatibilityCount: footer.shellCompatibilityCount,
manualCount: footer.manualCount,
conflictCount: footer.conflictCount,
triggerCount: footer.triggerCount,
outcome: footer.outcome,
preparedAtMs: header.preparedAtMs,
});
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
}
export function normalizeLocalReconciliationAutomationPlanReceipt(
value: unknown,
): Readonly<LocalReconciliationAutomationPlanReceipt> {
const receipt = exact(
value,
[
'applicationId',
'applicationPlanDigest',
'automationId',
'automationPlanDigest',
'conflictCount',
'eligibleCount',
'legacyInventoryDigest',
'manualCount',
'outcome',
'planFileBytes',
'planFileDigest',
'preparedHeadDigest',
'preparedAtMs',
'receiptDigest',
'rowCount',
'rowSetDigest',
'schema',
'schemaVersion',
'shellCompatibilityCount',
'state',
'triggerCount',
],
'receipt',
);
const { receiptDigest, ...payload } = receipt;
const counts = [
receipt.rowCount,
receipt.eligibleCount,
receipt.shellCompatibilityCount,
receipt.manualCount,
receipt.conflictCount,
receipt.triggerCount,
receipt.planFileBytes,
receipt.preparedAtMs,
];
if (
receipt.schema !== RECEIPT_SCHEMA ||
receipt.schemaVersion !== 1 ||
receipt.state !== 'reconciliation_automation_planned' ||
typeof receipt.automationId !== 'string' ||
!UUID_V4_PATTERN.test(receipt.automationId) ||
typeof receipt.applicationId !== 'string' ||
!UUID_V4_PATTERN.test(receipt.applicationId) ||
!counts.every((count) => Number.isSafeInteger(count) && (count as number) >= 0) ||
!['ready', 'manual_required', 'no_effect'].includes(
receipt.outcome as string,
) ||
[
receipt.applicationPlanDigest,
receipt.preparedHeadDigest,
receipt.legacyInventoryDigest,
receipt.rowSetDigest,
receipt.automationPlanDigest,
receipt.planFileDigest,
receiptDigest,
].some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
cutoverDigest(payload) !== receiptDigest
) {
configurationError('receipt drifted');
}
return Object.freeze(
receipt,
) as unknown as Readonly<LocalReconciliationAutomationPlanReceipt>;
}
export function hashLocalReconciliationAutomationPlanFile(
descriptor: number,
expectedBytes: number,
): string {
const hash = createHash('sha256');
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
let offset = 0;
while (offset < expectedBytes) {
const count = fs.readSync(
descriptor,
buffer,
0,
Math.min(buffer.byteLength, expectedBytes - offset),
offset,
);
if (count < 1) configurationError('plan file read stalled');
hash.update(buffer.subarray(0, count));
offset += count;
}
return hash.digest('hex');
}
@@ -81,6 +81,13 @@ interface TerminalApplication {
readonly receipt: Readonly<LocalReconciliationApplicationPlanReceipt>;
}
export interface LocalReconciliationApplicationTerminal
extends TerminalApplication {
readonly intent: Readonly<LocalReconciliationApplicationIntent>;
readonly review: Readonly<LocalReconciliationReviewTerminal>;
readonly head: Readonly<LocalCutoverInstanceHead>;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
@@ -662,6 +669,61 @@ function readTerminalApplication(
return terminal;
}
/**
* Re-opens a sealed application terminal for a domain adapter. The adapter is
* given the exact signed review and current instance head rather than a loose
* plan file, so no downstream DML authority can be inferred from copied JSON.
*/
export async function readLocalReconciliationApplicationTerminal(
applicationRoot: string,
applicationId: string,
uid: number,
): Promise<Readonly<LocalReconciliationApplicationTerminal>> {
const intent = readLocalReconciliationApplicationIntent(
applicationRoot,
applicationId,
);
if (
intent.command.options.applicationRoot !== applicationRoot ||
intent.command.request.applicationId !== applicationId
) {
configurationError('terminal reconciliation application path drifted');
}
const selected = applicationPaths(applicationRoot, applicationId);
validateDirectory(
selected.root,
uid,
[0o500],
'reconciliationApplicationDirectory',
);
validateDirectory(
selected.staging,
uid,
[0o500],
'reconciliationApplicationStaging',
);
validateCatalog(selected, true);
const review = await readReview(
intent.command,
intent.command.request.reviewId,
uid,
);
const terminal = readTerminalApplication(
selected,
intent,
review,
uid,
[0o400],
);
const head = readLocalCutoverInstanceHead(
intent.command.options.deploymentRoot,
intent.instanceId,
uid,
);
validateHeadIdentity(head, intent);
return Object.freeze({ intent, review, ...terminal, head });
}
function sealFile(filePath: string, uid: number): void {
let descriptor: number | undefined;
try {
@@ -755,7 +755,8 @@ function replayResult(
head.state === 'reconciliation_review_prepared' ||
head.state === 'reconciliation_reviewed' ||
head.state === 'reconciliation_application_prepared' ||
head.state === 'reconciliation_application_planned');
head.state === 'reconciliation_application_planned' ||
head.state === 'reconciliation_automation_planned');
if (
record.actionId !== intent.actionId ||
record.intentDigest !== intent.intentDigest ||
@@ -14,10 +14,12 @@ const {
commitLocalReconciliationReview,
prepareLocalReconciliationCapture,
prepareLocalReconciliationApplication,
planLocalReconciliationAutomation,
prepareLocalReconciliationPlan,
prepareLocalReconciliationReview,
verifyLocalReconciliationCapture,
verifyLocalReconciliationApplication,
verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationPlan,
verifyLocalReconciliationReview,
writeLocalReconciliationReviewDiagnostics,
@@ -25,6 +27,9 @@ const {
const {
normalizeLocalReconciliationCaptureManifest,
} = require('../dist/deployment/reconciliation/bundle.js');
const {
writeLocalReconciliationAutomationPlan,
} = require('../dist/deployment/reconciliation/application/automation/rowPlan.js');
const {
createLocalDataDirectoryApplicationCommit,
} = require('@qinglong/local-sqlite/data-directory-application-commit');
@@ -642,6 +647,75 @@ function planningDatabaseInitializer({ unknownTargetTable = false } = {}) {
};
}
function automationDatabaseInitializer() {
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
const legacy = new DatabaseSync(legacySourcePath);
legacy.exec(`
CREATE TABLE "Crontabs" (
id INTEGER PRIMARY KEY,
name TEXT,
command TEXT,
schedule TEXT,
saved INTEGER,
isSystem INTEGER,
isDisabled INTEGER,
isPinned INTEGER,
labels TEXT,
sub_id INTEGER,
extra_schedules TEXT,
task_before TEXT,
task_after TEXT,
log_name TEXT,
allow_multiple_instances INTEGER,
work_dir TEXT
);
INSERT INTO "Crontabs" (
id, name, command, schedule, saved, isSystem, isDisabled, isPinned
) VALUES (1, 'nightly', 'task nightly.js', '0 0 * * *', 1, 0, 0, 0);
`);
legacy.close();
fs.chmodSync(legacySourcePath, 0o600);
fs.copyFileSync(legacySourcePath, recoveryPath);
fs.chmodSync(recoveryPath, 0o600);
const target = new DatabaseSync(targetDatabasePath);
target.exec(`
CREATE TABLE "QingLong3SchemaCapabilities" (id INTEGER PRIMARY KEY);
CREATE TABLE "QingLong3TaskDefinitions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
current_revision INTEGER NOT NULL,
PRIMARY KEY (project_id, task_id)
);
CREATE TABLE "QingLong3TaskDefinitionRevisions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
revision INTEGER NOT NULL,
content_digest TEXT NOT NULL,
PRIMARY KEY (project_id, task_id, revision)
);
`);
target.close();
fs.chmodSync(targetDatabasePath, 0o600);
};
}
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
const target = new DatabaseSync(targetDatabasePath);
if (occupied) {
target.exec(`
INSERT INTO "QingLong3TaskDefinitions" VALUES ('default', 'legacy-cron:1', 1);
INSERT INTO "QingLong3TaskDefinitionRevisions" VALUES (
'default', 'legacy-cron:1', 1, '${'a'.repeat(64)}'
);
`);
} else {
target.exec('INSERT INTO "QingLong3SchemaCapabilities" (id) VALUES (1)');
}
target.close();
return Object.freeze({});
}
function mutatePlanningTarget({ targetDatabasePath }) {
const target = new DatabaseSync(targetDatabasePath);
target.exec('INSERT INTO "QingLong3TaskDefinitions" (id) VALUES (1)');
@@ -2547,6 +2621,523 @@ test('application coordinator plans eight content-free domains and verifies with
assert.equal(cli.stdout.includes('Crontabs'), 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',
reviewId: '00000000-0000-4000-8000-000000000432',
applicationId: '00000000-0000-4000-8000-000000000433',
reviewSuffix: 'automation-row-plan',
createDefaultSidecars: false,
initializeDatabases: automationDatabaseInitializer(),
mutateTarget(paths) {
return mutateAutomationTarget(paths);
},
mutateDecisions(records) {
const selected = records.find(
(record) =>
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
record.database === 'legacy' &&
record.domain === 'automation' &&
record.factKind === 'table' &&
record.disposition === 'exclude_legacy',
);
assert.ok(selected);
selected.disposition = 'adopt_legacy';
selected.reason = 'prefer_legacy';
},
});
const prepared = await prepareLocalReconciliationApplication(
state.prepareApplicationCommand,
);
const application = await commitLocalReconciliationApplication(
applicationCommitCommand(state, prepared),
);
const automationRoot = path.join(path.dirname(state.captureRoot), 'automation-root');
fs.mkdirSync(automationRoot, { mode: 0o700 });
const targetBefore = fs.readFileSync(state.targetDatabasePath);
const command = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.plan',
options: {
deploymentRoot: state.deploymentRoot,
applicationRoot: state.applicationRoot,
automationRoot,
allowRootService: rootAcknowledgement(),
},
request: {
automationId: '00000000-0000-4000-8000-000000000434',
applicationId: state.prepareApplicationCommand.request.applicationId,
expectedApplicationPlanDigest: application.applicationPlanDigest,
expectedHeadDigest: application.instanceHeadDigest,
decisionFilePath: state.reviewFile.filePath,
projectId: 'default',
legacyTimezone: 'Asia/Shanghai',
preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2,
},
};
const planned = await planLocalReconciliationAutomation(command);
assert.equal(planned.status, 'prepared');
assert.equal(planned.state, 'reconciliation_automation_planned');
assert.equal(planned.outcome, 'ready');
assert.equal(planned.rowCount, 1);
assert.equal(planned.eligibleCount, 1);
assert.equal(planned.conflictCount, 0);
const automationDirectory = path.join(
automationRoot,
command.request.automationId,
);
assert.deepEqual(fs.readdirSync(automationDirectory).sort(), [
'plan.ndjson',
'receipt.json',
'staging',
]);
assert.equal(fs.statSync(automationDirectory).mode & 0o777, 0o500);
assert.equal(
fs.statSync(path.join(automationDirectory, 'staging')).mode & 0o777,
0o500,
);
for (const name of ['plan.ndjson', 'receipt.json']) {
assert.equal(
fs.statSync(path.join(automationDirectory, name)).mode & 0o777,
0o400,
);
}
const planText = fs.readFileSync(
path.join(automationDirectory, 'plan.ndjson'),
'utf8',
);
assert.equal(planText.includes('task nightly.js'), false);
assert.equal(planText.includes('nightly'), false);
assert.equal(planText.includes('legacy-cron:1'), true);
assert.equal(planText.includes('review-owner'), false);
assert.equal(planText.includes('"requirement":"review_adopt"'), true);
assert.equal(fs.readFileSync(state.targetDatabasePath).equals(targetBefore), true);
const replay = await planLocalReconciliationAutomation(command);
assert.equal(replay.status, 'existing');
assert.equal(replay.automationPlanDigest, planned.automationPlanDigest);
const verifyCommand = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.verify',
options: command.options,
request: {
automationId: command.request.automationId,
expectedAutomationPlanDigest: planned.automationPlanDigest,
},
};
const verified = await verifyLocalReconciliationAutomationPlan(verifyCommand);
assert.equal(verified.status, 'verified');
const head = readLocalCutoverInstanceHead(
state.deploymentRoot,
state.captureCommand.request.instanceId,
state.uid,
);
assert.equal(head.state, 'reconciliation_automation_planned');
assert.equal(head.sourceRecordDigest, planned.automationPlanDigest);
const commandPath = path.join(state.deploymentRoot, 'automation-verify.json');
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
mode: 0o600,
});
const cli = spawnSync(
process.execPath,
[
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
'reconciliation-automation-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('legacy-cron:1'), false);
});
test('automation row plan fails closed to manual review on a target task collision', async (t) => {
const state = await reviewedApplicationFixture(t, {
planId: '00000000-0000-4000-8000-000000000435',
reviewId: '00000000-0000-4000-8000-000000000436',
applicationId: '00000000-0000-4000-8000-000000000437',
reviewSuffix: 'automation-row-conflict',
createDefaultSidecars: false,
initializeDatabases: automationDatabaseInitializer(),
mutateTarget(paths) {
return mutateAutomationTarget(paths, true);
},
mutateDecisions(records) {
const selected = records.find(
(record) =>
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
record.database === 'legacy' &&
record.domain === 'automation' &&
record.factKind === 'table' &&
record.disposition === 'exclude_legacy',
);
assert.ok(selected);
selected.disposition = 'retain_both';
selected.reason = 'preserve_both';
},
});
const prepared = await prepareLocalReconciliationApplication(
state.prepareApplicationCommand,
);
const application = await commitLocalReconciliationApplication(
applicationCommitCommand(state, prepared),
);
const automationRoot = path.join(
path.dirname(state.captureRoot),
'automation-conflict-root',
);
fs.mkdirSync(automationRoot, { mode: 0o700 });
const planned = await planLocalReconciliationAutomation({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.plan',
options: {
deploymentRoot: state.deploymentRoot,
applicationRoot: state.applicationRoot,
automationRoot,
allowRootService: rootAcknowledgement(),
},
request: {
automationId: '00000000-0000-4000-8000-000000000438',
applicationId: state.prepareApplicationCommand.request.applicationId,
expectedApplicationPlanDigest: application.applicationPlanDigest,
expectedHeadDigest: application.instanceHeadDigest,
decisionFilePath: state.reviewFile.filePath,
projectId: 'default',
legacyTimezone: 'Asia/Shanghai',
preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2,
},
});
assert.equal(planned.outcome, 'manual_required');
assert.equal(planned.eligibleCount, 0);
assert.equal(planned.conflictCount, 1);
const planText = fs.readFileSync(
path.join(
automationRoot,
'00000000-0000-4000-8000-000000000438',
'plan.ndjson',
),
'utf8',
);
assert.equal(planText.includes('"state":"occupied"'), true);
assert.equal(planText.includes('"requirement":"review_skip_conflict"'), true);
assert.equal(planText.includes('task nightly.js'), false);
});
test('automation row planner makes an empty table no-effect and a missing timezone manual', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-automation-row-unit-'));
t.after(() => removeFixtureRoot(root));
const legacySourcePath = path.join(root, 'legacy.sqlite');
const recoveryPath = path.join(root, 'recovery.sqlite');
const targetDatabasePath = path.join(root, 'target.sqlite');
automationDatabaseInitializer()({
legacySourcePath,
recoveryPath,
targetDatabasePath,
});
const legacy = new DatabaseSync(legacySourcePath);
const target = new DatabaseSync(targetDatabasePath);
t.after(() => {
legacy.close();
target.close();
});
legacy.exec('DELETE FROM "Crontabs"');
const header = {
schemaVersion: 1,
kind: 'qinglong3-local-reconciliation-automation-plan-header',
automationId: '00000000-0000-4000-8000-000000000439',
applicationId: '00000000-0000-4000-8000-00000000043a',
applicationPlanDigest: '1'.repeat(64),
reviewDigest: '2'.repeat(64),
reviewAuthorizationDigest: '3'.repeat(64),
reviewDecisionSetDigest: '4'.repeat(64),
reviewDecisionFileDigest: '5'.repeat(64),
bundleDigest: '6'.repeat(64),
bundleFingerprintDigest: '7'.repeat(64),
profile: 'edge',
projectId: 'default',
legacyTimezone: null,
tableDisposition: 'adopt_legacy',
preparedHeadDigest: '8'.repeat(64),
preparedAtMs: 1,
};
const emptyPath = path.join(root, 'empty.ndjson');
const emptyDescriptor = fs.openSync(emptyPath, 'wx', 0o600);
let empty;
try {
empty = writeLocalReconciliationAutomationPlan({
descriptor: emptyDescriptor,
maxBytes: 64 * 1024,
header,
legacy,
target,
});
} finally {
fs.closeSync(emptyDescriptor);
}
assert.equal(empty.footer.outcome, 'no_effect');
assert.equal(empty.footer.rowCount, 0);
legacy.exec(`
INSERT INTO "Crontabs" (
id, name, command, schedule, saved, isSystem, isDisabled, isPinned
) VALUES (2, 'timezone-required', 'task timezone.js', '0 0 * * *', 1, 0, 0, 0)
`);
const manualPath = path.join(root, 'manual.ndjson');
const manualDescriptor = fs.openSync(manualPath, 'wx', 0o600);
let manual;
try {
manual = writeLocalReconciliationAutomationPlan({
descriptor: manualDescriptor,
maxBytes: 64 * 1024,
header: { ...header, automationId: '00000000-0000-4000-8000-00000000043b' },
legacy,
target,
});
} finally {
fs.closeSync(manualDescriptor);
}
assert.equal(manual.footer.outcome, 'manual_required');
assert.equal(manual.footer.manualCount, 1);
const manualText = fs.readFileSync(manualPath, 'utf8');
assert.equal(manualText.includes('timezone_required'), true);
assert.equal(manualText.includes('task timezone.js'), false);
});
test('automation row planner fails closed at its byte budget', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-automation-budget-'));
t.after(() => removeFixtureRoot(root));
const legacySourcePath = path.join(root, 'legacy.sqlite');
const recoveryPath = path.join(root, 'recovery.sqlite');
const targetDatabasePath = path.join(root, 'target.sqlite');
automationDatabaseInitializer()({
legacySourcePath,
recoveryPath,
targetDatabasePath,
});
const legacy = new DatabaseSync(legacySourcePath);
const target = new DatabaseSync(targetDatabasePath);
t.after(() => {
legacy.close();
target.close();
});
const insert = legacy.prepare(`
INSERT INTO "Crontabs" (
id, name, command, schedule, saved, isSystem, isDisabled, isPinned
) VALUES (?, ?, ?, '0 0 * * *', 1, 0, 0, 0)
`);
legacy.exec('BEGIN IMMEDIATE');
try {
for (let id = 2; id <= 400; id += 1) {
insert.run(id, `task-${id}`, `task workload-${id}.js`);
}
legacy.exec('COMMIT');
} catch (error) {
legacy.exec('ROLLBACK');
throw error;
}
const outputPath = path.join(root, 'bounded.ndjson');
const descriptor = fs.openSync(outputPath, 'wx', 0o600);
try {
assert.throws(
() =>
writeLocalReconciliationAutomationPlan({
descriptor,
maxBytes: 64 * 1024,
header: {
schemaVersion: 1,
kind: 'qinglong3-local-reconciliation-automation-plan-header',
automationId: '00000000-0000-4000-8000-00000000043c',
applicationId: '00000000-0000-4000-8000-00000000043d',
applicationPlanDigest: '1'.repeat(64),
reviewDigest: '2'.repeat(64),
reviewAuthorizationDigest: '3'.repeat(64),
reviewDecisionSetDigest: '4'.repeat(64),
reviewDecisionFileDigest: '5'.repeat(64),
bundleDigest: '6'.repeat(64),
bundleFingerprintDigest: '7'.repeat(64),
profile: 'edge',
projectId: 'default',
legacyTimezone: 'UTC',
tableDisposition: 'adopt_legacy',
preparedHeadDigest: '8'.repeat(64),
preparedAtMs: 1,
},
legacy,
target,
}),
/exceeds profile byte budget/,
);
} finally {
fs.closeSync(descriptor);
}
assert.equal(fs.statSync(outputPath).size <= 64 * 1024, true);
});
test('automation row planning replays every publication boundary and rejects drift', async (t) => {
const windows = [
['plan', '000000000441', '000000000442', '000000000443', '000000000444'],
['receipt', '000000000445', '000000000446', '000000000447', '000000000448'],
['seal', '000000000449', '00000000044a', '00000000044b', '00000000044c'],
['head', '00000000044d', '00000000044e', '00000000044f', '000000000450'],
];
for (const [window, planTail, reviewTail, applicationTail, automationTail] of windows) {
const state = await reviewedApplicationFixture(t, {
planId: `00000000-0000-4000-8000-${planTail}`,
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
applicationId: `00000000-0000-4000-8000-${applicationTail}`,
reviewSuffix: `automation-${window}-replay`,
createDefaultSidecars: false,
initializeDatabases: automationDatabaseInitializer(),
mutateTarget(paths) {
return mutateAutomationTarget(paths);
},
mutateDecisions(records) {
const selected = records.find(
(record) =>
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
record.database === 'legacy' &&
record.domain === 'automation' &&
record.factKind === 'table' &&
record.disposition === 'exclude_legacy',
);
assert.ok(selected);
selected.disposition = 'adopt_legacy';
selected.reason = 'prefer_legacy';
},
});
const prepared = await prepareLocalReconciliationApplication(
state.prepareApplicationCommand,
);
const application = await commitLocalReconciliationApplication(
applicationCommitCommand(state, prepared),
);
const automationRoot = path.join(
path.dirname(state.captureRoot),
`automation-${window}-root`,
);
fs.mkdirSync(automationRoot, { mode: 0o700 });
const command = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.plan',
options: {
deploymentRoot: state.deploymentRoot,
applicationRoot: state.applicationRoot,
automationRoot,
allowRootService: rootAcknowledgement(),
},
request: {
automationId: `00000000-0000-4000-8000-${automationTail}`,
applicationId: state.prepareApplicationCommand.request.applicationId,
expectedApplicationPlanDigest: application.applicationPlanDigest,
expectedHeadDigest: application.instanceHeadDigest,
decisionFilePath: state.reviewFile.filePath,
projectId: 'default',
legacyTimezone: 'Asia/Shanghai',
preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2,
},
};
const callback =
window === 'plan'
? 'afterPlanPublished'
: window === 'receipt'
? 'afterReceiptPublished'
: window === 'seal'
? 'afterTerminalSealed'
: 'afterHeadAdvanced';
await assert.rejects(
planLocalReconciliationAutomation(command, {
[callback]() {
throw new Error(`automation ${window} response loss`);
},
}),
new RegExp(`automation ${window} response loss`),
);
const replay = await planLocalReconciliationAutomation(command);
assert.equal(replay.state, 'reconciliation_automation_planned');
if (window === 'head') assert.equal(replay.status, 'existing');
}
const drift = await reviewedApplicationFixture(t, {
planId: '00000000-0000-4000-8000-000000000451',
reviewId: '00000000-0000-4000-8000-000000000452',
applicationId: '00000000-0000-4000-8000-000000000453',
reviewSuffix: 'automation-drift',
createDefaultSidecars: false,
initializeDatabases: automationDatabaseInitializer(),
mutateTarget(paths) {
return mutateAutomationTarget(paths);
},
mutateDecisions(records) {
const selected = records.find(
(record) =>
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
record.database === 'legacy' &&
record.domain === 'automation' &&
record.factKind === 'table' &&
record.disposition === 'exclude_legacy',
);
assert.ok(selected);
selected.disposition = 'adopt_legacy';
selected.reason = 'prefer_legacy';
},
});
const prepared = await prepareLocalReconciliationApplication(
drift.prepareApplicationCommand,
);
const application = await commitLocalReconciliationApplication(
applicationCommitCommand(drift, prepared),
);
const automationRoot = path.join(
path.dirname(drift.captureRoot),
'automation-drift-root',
);
fs.mkdirSync(automationRoot, { mode: 0o700 });
const command = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.plan',
options: {
deploymentRoot: drift.deploymentRoot,
applicationRoot: drift.applicationRoot,
automationRoot,
allowRootService: rootAcknowledgement(),
},
request: {
automationId: '00000000-0000-4000-8000-000000000454',
applicationId: drift.prepareApplicationCommand.request.applicationId,
expectedApplicationPlanDigest: application.applicationPlanDigest,
expectedHeadDigest: application.instanceHeadDigest,
decisionFilePath: drift.reviewFile.filePath,
projectId: 'default',
legacyTimezone: 'Asia/Shanghai',
preparedAtMs: drift.prepareApplicationCommand.request.preparedAtMs + 2,
},
};
const planned = await planLocalReconciliationAutomation(command);
const planPath = path.join(
automationRoot,
command.request.automationId,
'plan.ndjson',
);
fs.chmodSync(planPath, 0o600);
fs.appendFileSync(planPath, '{}\n');
fs.chmodSync(planPath, 0o400);
await assert.rejects(
verifyLocalReconciliationAutomationPlan({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.verify',
options: command.options,
request: {
automationId: command.request.automationId,
expectedAutomationPlanDigest: planned.automationPlanDigest,
},
}),
/plan file identity is invalid|plan file content drifted/,
);
});
test('application coordinator resumes every publication window and fences competitors', async (t) => {
const prepareCrash = await reviewedApplicationFixture(t, {
planId: '00000000-0000-4000-8000-000000000410',