mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prepare private reconciliation reviews
This commit is contained in:
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-21):
|
最新增量证据(2026-08-21):
|
||||||
|
|
||||||
- D-391/ADR-0484(契约已冻结,待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或获得 import authority。
|
- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或
|
||||||
下一切片在既有 Local Owner 的 `deployment/reconciliation/review/` 增加
|
获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加
|
||||||
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
`reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结
|
||||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把
|
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把
|
||||||
一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和
|
一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和
|
||||||
offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、
|
offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、
|
||||||
@@ -22,7 +22,10 @@
|
|||||||
exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。
|
exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。
|
||||||
verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/
|
verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/
|
||||||
network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把
|
network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把
|
||||||
review completion 当作 reconciliation completion。
|
review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner
|
||||||
|
`250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`,
|
||||||
|
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18
|
||||||
|
packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。
|
||||||
- D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立
|
- D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立
|
||||||
`reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* /
|
`reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* /
|
||||||
recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot
|
recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ import Local SQLite mutation、adoption publisher 或 runtime execution authorit
|
|||||||
运行时最多一个 SQLite handle、一个 decision iterator、64 KiB hash/read buffer、64 条 page record 和固定八领域 counters。diagnostics
|
运行时最多一个 SQLite handle、一个 decision iterator、64 KiB hash/read buffer、64 条 page record 和固定八领域 counters。diagnostics
|
||||||
output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes 不读取 Local review root、authorization 或 reviewer identity。
|
output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes 不读取 Local review root、authorization 或 reviewer identity。
|
||||||
|
|
||||||
|
## 当前实现进度
|
||||||
|
|
||||||
|
D-391 第一切片已实现 `review.prepare` 与 `review.diagnostics`。prepare 读取并重验 exact terminal plan/bundle,以 instance CAS 建立
|
||||||
|
`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次
|
||||||
|
SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown
|
||||||
|
facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic
|
||||||
|
stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。
|
||||||
|
|
||||||
|
验证结果:聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner
|
||||||
|
`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`;
|
||||||
|
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18
|
||||||
|
packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增
|
||||||
|
3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。
|
||||||
|
|
||||||
|
`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本
|
||||||
|
ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。
|
||||||
|
|
||||||
## 被拒绝的替代方案
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
### 把对象名和冲突列表直接加入 plan
|
### 把对象名和冲突列表直接加入 plan
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_captured'
|
| 'reconciliation_captured'
|
||||||
| 'reconciliation_plan_prepared'
|
| 'reconciliation_plan_prepared'
|
||||||
| 'reconciliation_planned'
|
| 'reconciliation_planned'
|
||||||
|
| 'reconciliation_review_prepared'
|
||||||
|
| 'reconciliation_reviewed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -162,6 +164,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_captured' &&
|
head.state !== 'reconciliation_captured' &&
|
||||||
head.state !== 'reconciliation_plan_prepared' &&
|
head.state !== 'reconciliation_plan_prepared' &&
|
||||||
head.state !== 'reconciliation_planned' &&
|
head.state !== 'reconciliation_planned' &&
|
||||||
|
head.state !== 'reconciliation_review_prepared' &&
|
||||||
|
head.state !== 'reconciliation_reviewed' &&
|
||||||
head.state !== 'rollback_prepared' &&
|
head.state !== 'rollback_prepared' &&
|
||||||
head.state !== 'legacy_restart_requested' &&
|
head.state !== 'legacy_restart_requested' &&
|
||||||
head.state !== 'legacy_running' &&
|
head.state !== 'legacy_running' &&
|
||||||
@@ -332,6 +336,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_captured'
|
| 'reconciliation_captured'
|
||||||
| 'reconciliation_plan_prepared'
|
| 'reconciliation_plan_prepared'
|
||||||
| 'reconciliation_planned'
|
| 'reconciliation_planned'
|
||||||
|
| 'reconciliation_review_prepared'
|
||||||
|
| 'reconciliation_reviewed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -374,6 +380,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_captured' ||
|
current.state === 'reconciliation_captured' ||
|
||||||
current.state === 'reconciliation_plan_prepared' ||
|
current.state === 'reconciliation_plan_prepared' ||
|
||||||
current.state === 'reconciliation_planned' ||
|
current.state === 'reconciliation_planned' ||
|
||||||
|
current.state === 'reconciliation_review_prepared' ||
|
||||||
|
current.state === 'reconciliation_reviewed' ||
|
||||||
current.state === 'legacy_restart_requested' ||
|
current.state === 'legacy_restart_requested' ||
|
||||||
current.state === 'legacy_running' ||
|
current.state === 'legacy_running' ||
|
||||||
current.state === 'legacy_ready')
|
current.state === 'legacy_ready')
|
||||||
@@ -397,6 +405,10 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_captured') ||
|
current.state === 'reconciliation_captured') ||
|
||||||
(state === 'reconciliation_planned' &&
|
(state === 'reconciliation_planned' &&
|
||||||
current.state === 'reconciliation_plan_prepared') ||
|
current.state === 'reconciliation_plan_prepared') ||
|
||||||
|
(state === 'reconciliation_review_prepared' &&
|
||||||
|
current.state === 'reconciliation_planned') ||
|
||||||
|
(state === 'reconciliation_reviewed' &&
|
||||||
|
current.state === 'reconciliation_review_prepared') ||
|
||||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||||
(state === 'legacy_restart_requested' &&
|
(state === 'legacy_restart_requested' &&
|
||||||
current.state === 'rollback_prepared') ||
|
current.state === 'rollback_prepared') ||
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ import {
|
|||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationPlanCommandFile,
|
verifyLocalReconciliationPlanCommandFile,
|
||||||
} from './reconciliation/planning/preparation';
|
} from './reconciliation/planning/preparation';
|
||||||
|
import {
|
||||||
|
prepareLocalReconciliationReview,
|
||||||
|
prepareLocalReconciliationReviewCommandFile,
|
||||||
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
|
} from './reconciliation/review/preparation';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -119,6 +125,10 @@ export {
|
|||||||
prepareLocalReconciliationPlanCommandFile,
|
prepareLocalReconciliationPlanCommandFile,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationPlanCommandFile,
|
verifyLocalReconciliationPlanCommandFile,
|
||||||
|
prepareLocalReconciliationReview,
|
||||||
|
prepareLocalReconciliationReviewCommandFile,
|
||||||
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -160,6 +170,30 @@ export {
|
|||||||
type LocalReconciliationCaptureVerifyCommand,
|
type LocalReconciliationCaptureVerifyCommand,
|
||||||
type LocalReconciliationStoppedAuthority,
|
type LocalReconciliationStoppedAuthority,
|
||||||
} from './reconciliation/contract';
|
} from './reconciliation/contract';
|
||||||
|
export {
|
||||||
|
LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS,
|
||||||
|
normalizeLocalReconciliationReviewDiagnosticsCommand,
|
||||||
|
normalizeLocalReconciliationReviewPrepareCommand,
|
||||||
|
type LocalReconciliationDiagnosticFactKind,
|
||||||
|
type LocalReconciliationReviewDiagnosticsCommand,
|
||||||
|
type LocalReconciliationReviewDiagnosticsResult,
|
||||||
|
type LocalReconciliationReviewOptions,
|
||||||
|
type LocalReconciliationReviewPrepareCommand,
|
||||||
|
type LocalReconciliationReviewPrepareResult,
|
||||||
|
} from './reconciliation/review/contract';
|
||||||
|
export {
|
||||||
|
localReconciliationReviewDirectory,
|
||||||
|
normalizeLocalReconciliationReviewIntent,
|
||||||
|
readLocalReconciliationReviewIntent,
|
||||||
|
type LocalReconciliationReviewDependencies,
|
||||||
|
type LocalReconciliationReviewIntent,
|
||||||
|
} from './reconciliation/review/preparation';
|
||||||
|
export {
|
||||||
|
type LocalReconciliationDiagnosticDecisionRequirement,
|
||||||
|
type LocalReconciliationDiagnosticFact,
|
||||||
|
type LocalReconciliationDiagnosticPage,
|
||||||
|
type LocalReconciliationDiagnosticReason,
|
||||||
|
} from './reconciliation/review/diagnostics';
|
||||||
export {
|
export {
|
||||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||||
normalizeLocalReconciliationPlanCommitCommand,
|
normalizeLocalReconciliationPlanCommitCommand,
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||||
prepareLocalReconciliationCaptureCommandFile,
|
prepareLocalReconciliationCaptureCommandFile,
|
||||||
prepareLocalReconciliationPlanCommandFile,
|
prepareLocalReconciliationPlanCommandFile,
|
||||||
|
prepareLocalReconciliationReviewCommandFile,
|
||||||
commitLocalReconciliationCaptureCommandFile,
|
commitLocalReconciliationCaptureCommandFile,
|
||||||
commitLocalReconciliationPlanCommandFile,
|
commitLocalReconciliationPlanCommandFile,
|
||||||
verifyLocalReconciliationCaptureCommandFile,
|
verifyLocalReconciliationCaptureCommandFile,
|
||||||
verifyLocalReconciliationPlanCommandFile,
|
verifyLocalReconciliationPlanCommandFile,
|
||||||
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
restoreLocalDeploymentComposeCommitCommandFile,
|
restoreLocalDeploymentComposeCommitCommandFile,
|
||||||
@@ -33,7 +35,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
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|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|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> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
@@ -68,6 +70,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-plan-prepare' &&
|
argv[0] !== 'reconciliation-plan-prepare' &&
|
||||||
argv[0] !== 'reconciliation-plan-commit' &&
|
argv[0] !== 'reconciliation-plan-commit' &&
|
||||||
argv[0] !== 'reconciliation-plan-verify' &&
|
argv[0] !== 'reconciliation-plan-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-review-prepare' &&
|
||||||
|
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
argv[0] !== 'compose-preflight' &&
|
argv[0] !== 'compose-preflight' &&
|
||||||
argv[0] !== 'compose-apply' &&
|
argv[0] !== 'compose-apply' &&
|
||||||
@@ -150,6 +154,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? commitLocalReconciliationPlanCommandFile(argv[2]!)
|
? commitLocalReconciliationPlanCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-plan-verify'
|
: argv[0] === 'reconciliation-plan-verify'
|
||||||
? verifyLocalReconciliationPlanCommandFile(argv[2]!)
|
? verifyLocalReconciliationPlanCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-review-prepare'
|
||||||
|
? prepareLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-review-diagnostics'
|
||||||
|
? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-revision'
|
: argv[0] === 'compose-revision'
|
||||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-preflight'
|
: argv[0] === 'compose-preflight'
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ function classify(
|
|||||||
return kind === 'legacy' ? legacyDomain(name) : targetDomain(name);
|
return kind === 'legacy' ? legacyDomain(name) : targetDomain(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classifyLocalReconciliationFact(
|
||||||
|
kind: LocalReconciliationSealedDatabaseKind,
|
||||||
|
name: string,
|
||||||
|
): LocalReconciliationPlanDomain {
|
||||||
|
return classify(kind, name);
|
||||||
|
}
|
||||||
|
|
||||||
function quotedIdentifier(name: string): string {
|
function quotedIdentifier(name: string): string {
|
||||||
return `"${name.replaceAll('"', '""')}"`;
|
return `"${name.replaceAll('"', '""')}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,6 +448,35 @@ function readTerminal(
|
|||||||
return Object.freeze({ plan, receipt });
|
return Object.freeze({ plan, receipt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationPlanTerminal {
|
||||||
|
readonly intent: Readonly<LocalReconciliationPlanIntent>;
|
||||||
|
readonly bundle: Readonly<LocalReconciliationSealedBundle>;
|
||||||
|
readonly plan: Readonly<LocalReconciliationPlan>;
|
||||||
|
readonly receipt: Readonly<LocalReconciliationPlanReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationPlanTerminal(
|
||||||
|
planRoot: string,
|
||||||
|
planId: string,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationPlanTerminal> {
|
||||||
|
const paths = planPaths(planRoot, planId);
|
||||||
|
const intent = readLocalReconciliationPlanIntent(planRoot, planId);
|
||||||
|
if (
|
||||||
|
intent.command.options.planRoot !== planRoot ||
|
||||||
|
intent.command.request.planId !== planId
|
||||||
|
) {
|
||||||
|
configurationError('terminal reconciliation plan path binding drifted');
|
||||||
|
}
|
||||||
|
const bundle = inspectLocalReconciliationSealedBundle(
|
||||||
|
intent.command.options.captureRoot,
|
||||||
|
intent.command.request.captureId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const terminal = readTerminal(paths, intent, bundle, uid);
|
||||||
|
return Object.freeze({ intent, bundle, ...terminal });
|
||||||
|
}
|
||||||
|
|
||||||
function result(
|
function result(
|
||||||
operation: LocalReconciliationPlanTerminalResult['operation'],
|
operation: LocalReconciliationPlanTerminalResult['operation'],
|
||||||
status: LocalReconciliationPlanTerminalResult['status'],
|
status: LocalReconciliationPlanTerminalResult['status'],
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||||
|
import {
|
||||||
|
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||||
|
type LocalReconciliationPlanDomain,
|
||||||
|
} from '../planning/contract';
|
||||||
|
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
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_PATH_BYTES = 4_096;
|
||||||
|
|
||||||
|
export const LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS = Object.freeze([
|
||||||
|
'schema_object',
|
||||||
|
'table',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type LocalReconciliationDiagnosticFactKind =
|
||||||
|
(typeof LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS)[number];
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly captureRoot: string;
|
||||||
|
readonly planRoot: string;
|
||||||
|
readonly reviewRoot: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewPrepareCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.review.prepare';
|
||||||
|
readonly options: Readonly<LocalReconciliationReviewOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
reviewId: string;
|
||||||
|
planId: string;
|
||||||
|
expectedPlanDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
preparedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewDiagnosticsCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.review.diagnostics';
|
||||||
|
readonly options: Readonly<LocalReconciliationReviewOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
reviewId: string;
|
||||||
|
expectedPreparationDigest: string;
|
||||||
|
database: 'legacy' | 'target';
|
||||||
|
domain: LocalReconciliationPlanDomain;
|
||||||
|
factKind: LocalReconciliationDiagnosticFactKind;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
outputPath: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewPrepareResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.review.prepare';
|
||||||
|
readonly status: 'prepared' | 'existing';
|
||||||
|
readonly state: 'reconciliation_review_prepared';
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewDiagnosticsResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.review.diagnostics';
|
||||||
|
readonly status: 'prepared' | 'existing';
|
||||||
|
readonly state: 'reconciliation_review_prepared';
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly pageDigest: string;
|
||||||
|
readonly recordCount: number;
|
||||||
|
readonly complete: boolean;
|
||||||
|
readonly nextOffset: number | null;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(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 safeAbsolutePath(value: unknown, label: string): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!path.isAbsolute(value) ||
|
||||||
|
path.normalize(value) !== value ||
|
||||||
|
path.parse(value).root === 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 UUID v4`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(value: unknown): Readonly<LocalReconciliationReviewOptions> {
|
||||||
|
const options = object(value, 'options');
|
||||||
|
exact(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
'allowRootService',
|
||||||
|
'captureRoot',
|
||||||
|
'deploymentRoot',
|
||||||
|
'planRoot',
|
||||||
|
'reviewRoot',
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
if (
|
||||||
|
typeof options.allowRootService !== 'boolean' ||
|
||||||
|
(identity.uid === 0) !== options.allowRootService
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review command identity is invalid');
|
||||||
|
}
|
||||||
|
const roots = [
|
||||||
|
safeAbsolutePath(options.deploymentRoot, 'deploymentRoot'),
|
||||||
|
safeAbsolutePath(options.captureRoot, 'captureRoot'),
|
||||||
|
safeAbsolutePath(options.planRoot, 'planRoot'),
|
||||||
|
safeAbsolutePath(options.reviewRoot, 'reviewRoot'),
|
||||||
|
];
|
||||||
|
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, capture, plan and review roots must not overlap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: roots[0]!,
|
||||||
|
captureRoot: roots[1]!,
|
||||||
|
planRoot: roots[2]!,
|
||||||
|
reviewRoot: roots[3]!,
|
||||||
|
allowRootService: options.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCommand(
|
||||||
|
value: unknown,
|
||||||
|
operation:
|
||||||
|
| 'local.deployment.reconciliation.review.prepare'
|
||||||
|
| 'local.deployment.reconciliation.review.diagnostics',
|
||||||
|
): Readonly<{
|
||||||
|
options: Readonly<LocalReconciliationReviewOptions>;
|
||||||
|
request: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const command = object(value, 'reconciliation review command');
|
||||||
|
exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command');
|
||||||
|
if (command.schemaVersion !== 1 || command.operation !== operation) {
|
||||||
|
configurationError('reconciliation review command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
options: normalizeOptions(command.options),
|
||||||
|
request: object(command.request, 'request'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationReviewPrepareCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationReviewPrepareCommand> {
|
||||||
|
const command = normalizeCommand(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.review.prepare',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
command.request,
|
||||||
|
[
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'expectedPlanDigest',
|
||||||
|
'planId',
|
||||||
|
'preparedAtMs',
|
||||||
|
'reviewId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(command.request.preparedAtMs) ||
|
||||||
|
(command.request.preparedAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review prepare command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
operation: 'local.deployment.reconciliation.review.prepare' as const,
|
||||||
|
options: command.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
reviewId: identifier(command.request.reviewId, 'reviewId'),
|
||||||
|
planId: identifier(command.request.planId, 'planId'),
|
||||||
|
expectedPlanDigest: digest(
|
||||||
|
command.request.expectedPlanDigest,
|
||||||
|
'expectedPlanDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
command.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
preparedAtMs: command.request.preparedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationReviewDiagnosticsCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationReviewDiagnosticsCommand> {
|
||||||
|
const command = normalizeCommand(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.review.diagnostics',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
command.request,
|
||||||
|
[
|
||||||
|
'database',
|
||||||
|
'domain',
|
||||||
|
'expectedPreparationDigest',
|
||||||
|
'factKind',
|
||||||
|
'limit',
|
||||||
|
'offset',
|
||||||
|
'outputPath',
|
||||||
|
'reviewId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(command.request.database !== 'legacy' &&
|
||||||
|
command.request.database !== 'target') ||
|
||||||
|
!LOCAL_RECONCILIATION_PLAN_DOMAINS.includes(
|
||||||
|
command.request.domain as LocalReconciliationPlanDomain,
|
||||||
|
) ||
|
||||||
|
!LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS.includes(
|
||||||
|
command.request.factKind as LocalReconciliationDiagnosticFactKind,
|
||||||
|
) ||
|
||||||
|
!Number.isSafeInteger(command.request.offset) ||
|
||||||
|
(command.request.offset as number) < 0 ||
|
||||||
|
(command.request.offset as number) > 4_096 ||
|
||||||
|
!Number.isSafeInteger(command.request.limit) ||
|
||||||
|
(command.request.limit as number) < 1 ||
|
||||||
|
(command.request.limit as number) > 64
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review diagnostics command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
operation: 'local.deployment.reconciliation.review.diagnostics' as const,
|
||||||
|
options: command.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
reviewId: identifier(command.request.reviewId, 'reviewId'),
|
||||||
|
expectedPreparationDigest: digest(
|
||||||
|
command.request.expectedPreparationDigest,
|
||||||
|
'expectedPreparationDigest',
|
||||||
|
),
|
||||||
|
database: command.request.database,
|
||||||
|
domain: command.request.domain as LocalReconciliationPlanDomain,
|
||||||
|
factKind:
|
||||||
|
command.request.factKind as LocalReconciliationDiagnosticFactKind,
|
||||||
|
offset: command.request.offset as number,
|
||||||
|
limit: command.request.limit as number,
|
||||||
|
outputPath: safeAbsolutePath(command.request.outputPath, 'outputPath'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||||
|
import { validatePrivateDirectory } from '../../foundation/files';
|
||||||
|
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||||
|
import { classifyLocalReconciliationFact } from '../planning/inventory';
|
||||||
|
import type { LocalReconciliationPlanDomain } from '../planning/contract';
|
||||||
|
import type {
|
||||||
|
LocalReconciliationSealedBundle,
|
||||||
|
LocalReconciliationSealedDatabaseKind,
|
||||||
|
} from '../sealed-bundle/reader';
|
||||||
|
import type {
|
||||||
|
LocalReconciliationDiagnosticFactKind,
|
||||||
|
LocalReconciliationReviewDiagnosticsCommand,
|
||||||
|
} from './contract';
|
||||||
|
|
||||||
|
const PAGE_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-page';
|
||||||
|
const FACT_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-fact';
|
||||||
|
const MAX_PAGE_BYTES = 256 * 1024;
|
||||||
|
const MAX_NAME_BYTES = 1_024;
|
||||||
|
|
||||||
|
export type LocalReconciliationDiagnosticDecisionRequirement =
|
||||||
|
| 'informational'
|
||||||
|
| 'required'
|
||||||
|
| 'blocked';
|
||||||
|
|
||||||
|
export type LocalReconciliationDiagnosticReason =
|
||||||
|
| 'catalog_evidence'
|
||||||
|
| 'reviewable_fact'
|
||||||
|
| 'unknown_schema'
|
||||||
|
| 'secret_custody_required'
|
||||||
|
| 'historical_integrity_required'
|
||||||
|
| 'identity_custody_required';
|
||||||
|
|
||||||
|
export interface LocalReconciliationDiagnosticFact {
|
||||||
|
readonly schema: typeof FACT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly ordinal: number;
|
||||||
|
readonly database: LocalReconciliationSealedDatabaseKind;
|
||||||
|
readonly domain: LocalReconciliationPlanDomain;
|
||||||
|
readonly factKind: LocalReconciliationDiagnosticFactKind;
|
||||||
|
readonly objectType: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly tableName: string;
|
||||||
|
readonly rowCount: string | null;
|
||||||
|
readonly decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||||
|
readonly reason: LocalReconciliationDiagnosticReason;
|
||||||
|
readonly factDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationDiagnosticPage {
|
||||||
|
readonly schema: typeof PAGE_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_review_prepared';
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly planId: string;
|
||||||
|
readonly planDigest: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly bundleDigest: string;
|
||||||
|
readonly bundleFingerprintDigest: string;
|
||||||
|
readonly database: LocalReconciliationSealedDatabaseKind;
|
||||||
|
readonly domain: LocalReconciliationPlanDomain;
|
||||||
|
readonly factKind: LocalReconciliationDiagnosticFactKind;
|
||||||
|
readonly offset: number;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly recordCount: number;
|
||||||
|
readonly complete: boolean;
|
||||||
|
readonly nextOffset: number | null;
|
||||||
|
readonly records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||||
|
readonly pageDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildLocalReconciliationDiagnosticPageOptions {
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly planId: string;
|
||||||
|
readonly planDigest: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly bundle: Readonly<LocalReconciliationSealedBundle>;
|
||||||
|
readonly command: Readonly<LocalReconciliationReviewDiagnosticsCommand>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedIdentifier(name: string): string {
|
||||||
|
return `"${name.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirement(
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
): Readonly<{
|
||||||
|
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||||
|
reason: LocalReconciliationDiagnosticReason;
|
||||||
|
}> {
|
||||||
|
if (domain === 'unknown') {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'blocked' as const,
|
||||||
|
reason: 'unknown_schema' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (domain === 'secret_and_config') {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'blocked' as const,
|
||||||
|
reason: 'secret_custody_required' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (domain === 'run_history') {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'blocked' as const,
|
||||||
|
reason: 'historical_integrity_required' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (domain === 'identity_policy_audit') {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'blocked' as const,
|
||||||
|
reason: 'identity_custody_required' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (domain === 'schema_lineage') {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'informational' as const,
|
||||||
|
reason: 'catalog_evidence' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'required' as const,
|
||||||
|
reason: 'reviewable_fact' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowCount(
|
||||||
|
client: DatabaseSync,
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
tableName: string,
|
||||||
|
objectType: string,
|
||||||
|
): string | null {
|
||||||
|
if (domain === 'unknown' || objectType !== 'table') return null;
|
||||||
|
const counted = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT CAST(COUNT(*) AS TEXT) AS countText FROM ${quotedIdentifier(
|
||||||
|
tableName,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
.get() as { readonly countText?: unknown } | undefined;
|
||||||
|
if (
|
||||||
|
typeof counted?.countText !== 'string' ||
|
||||||
|
!/^(?:0|[1-9][0-9]*)$/.test(counted.countText)
|
||||||
|
) {
|
||||||
|
configurationError('diagnostic table row count drifted');
|
||||||
|
}
|
||||||
|
return counted.countText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(
|
||||||
|
ordinal: number,
|
||||||
|
database: LocalReconciliationSealedDatabaseKind,
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
factKind: LocalReconciliationDiagnosticFactKind,
|
||||||
|
objectType: string,
|
||||||
|
name: string,
|
||||||
|
tableName: string,
|
||||||
|
count: string | null,
|
||||||
|
): Readonly<LocalReconciliationDiagnosticFact> {
|
||||||
|
const review = requirement(domain);
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: FACT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
ordinal,
|
||||||
|
database,
|
||||||
|
domain,
|
||||||
|
factKind,
|
||||||
|
objectType,
|
||||||
|
name,
|
||||||
|
tableName,
|
||||||
|
rowCount: count,
|
||||||
|
decisionRequirement: review.decisionRequirement,
|
||||||
|
reason: review.reason,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, factDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaFacts(
|
||||||
|
client: DatabaseSync,
|
||||||
|
database: LocalReconciliationSealedDatabaseKind,
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||||
|
const rows = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT type, name, tbl_name AS tableName
|
||||||
|
FROM sqlite_schema
|
||||||
|
WHERE name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY type, name
|
||||||
|
LIMIT 4097`,
|
||||||
|
)
|
||||||
|
.iterate() as IterableIterator<{
|
||||||
|
readonly type?: unknown;
|
||||||
|
readonly name?: unknown;
|
||||||
|
readonly tableName?: unknown;
|
||||||
|
}>;
|
||||||
|
const records: LocalReconciliationDiagnosticFact[] = [];
|
||||||
|
let seen = 0;
|
||||||
|
let catalog = 0;
|
||||||
|
let complete = true;
|
||||||
|
for (const row of rows) {
|
||||||
|
catalog += 1;
|
||||||
|
if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded');
|
||||||
|
if (
|
||||||
|
typeof row.type !== 'string' ||
|
||||||
|
typeof row.name !== 'string' ||
|
||||||
|
typeof row.tableName !== 'string' ||
|
||||||
|
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES ||
|
||||||
|
Buffer.byteLength(row.tableName, 'utf8') > MAX_NAME_BYTES
|
||||||
|
) {
|
||||||
|
configurationError('diagnostic schema catalog drifted');
|
||||||
|
}
|
||||||
|
if (classifyLocalReconciliationFact(database, row.tableName) !== domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ordinal = seen + 1;
|
||||||
|
seen += 1;
|
||||||
|
if (seen <= offset) continue;
|
||||||
|
if (records.length >= limit) {
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
records.push(
|
||||||
|
fact(
|
||||||
|
ordinal,
|
||||||
|
database,
|
||||||
|
domain,
|
||||||
|
'schema_object',
|
||||||
|
row.type,
|
||||||
|
row.name,
|
||||||
|
row.tableName,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ records: Object.freeze(records), complete });
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableFacts(
|
||||||
|
client: DatabaseSync,
|
||||||
|
database: LocalReconciliationSealedDatabaseKind,
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||||
|
const rows = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT name, type
|
||||||
|
FROM pragma_table_list
|
||||||
|
WHERE schema = 'main' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 513`,
|
||||||
|
)
|
||||||
|
.iterate() as IterableIterator<{
|
||||||
|
readonly name?: unknown;
|
||||||
|
readonly type?: unknown;
|
||||||
|
}>;
|
||||||
|
const records: LocalReconciliationDiagnosticFact[] = [];
|
||||||
|
let seen = 0;
|
||||||
|
let catalog = 0;
|
||||||
|
let complete = true;
|
||||||
|
for (const row of rows) {
|
||||||
|
catalog += 1;
|
||||||
|
if (catalog > 512) configurationError('diagnostic table budget is exceeded');
|
||||||
|
if (
|
||||||
|
typeof row.name !== 'string' ||
|
||||||
|
typeof row.type !== 'string' ||
|
||||||
|
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES
|
||||||
|
) {
|
||||||
|
configurationError('diagnostic table catalog drifted');
|
||||||
|
}
|
||||||
|
if (classifyLocalReconciliationFact(database, row.name) !== domain) continue;
|
||||||
|
const ordinal = seen + 1;
|
||||||
|
seen += 1;
|
||||||
|
if (seen <= offset) continue;
|
||||||
|
if (records.length >= limit) {
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
records.push(
|
||||||
|
fact(
|
||||||
|
ordinal,
|
||||||
|
database,
|
||||||
|
domain,
|
||||||
|
'table',
|
||||||
|
row.type,
|
||||||
|
row.name,
|
||||||
|
row.name,
|
||||||
|
rowCount(client, domain, row.name, row.type),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ records: Object.freeze(records), complete });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationDiagnosticPage(
|
||||||
|
client: DatabaseSync,
|
||||||
|
options: Readonly<BuildLocalReconciliationDiagnosticPageOptions>,
|
||||||
|
): Readonly<LocalReconciliationDiagnosticPage> {
|
||||||
|
const request = options.command.request;
|
||||||
|
const selected =
|
||||||
|
request.factKind === 'schema_object'
|
||||||
|
? schemaFacts(
|
||||||
|
client,
|
||||||
|
request.database,
|
||||||
|
request.domain,
|
||||||
|
request.offset,
|
||||||
|
request.limit,
|
||||||
|
)
|
||||||
|
: tableFacts(
|
||||||
|
client,
|
||||||
|
request.database,
|
||||||
|
request.domain,
|
||||||
|
request.offset,
|
||||||
|
request.limit,
|
||||||
|
);
|
||||||
|
const nextOffset = selected.complete
|
||||||
|
? null
|
||||||
|
: request.offset + selected.records.length;
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: PAGE_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_review_prepared' as const,
|
||||||
|
reviewId: options.reviewId,
|
||||||
|
planId: options.planId,
|
||||||
|
planDigest: options.planDigest,
|
||||||
|
preparationDigest: options.preparationDigest,
|
||||||
|
bundleDigest: options.bundle.receipt.bundleDigest,
|
||||||
|
bundleFingerprintDigest: options.bundle.fingerprintDigest,
|
||||||
|
database: request.database,
|
||||||
|
domain: request.domain,
|
||||||
|
factKind: request.factKind,
|
||||||
|
offset: request.offset,
|
||||||
|
limit: request.limit,
|
||||||
|
recordCount: selected.records.length,
|
||||||
|
complete: selected.complete,
|
||||||
|
nextOffset,
|
||||||
|
records: selected.records,
|
||||||
|
});
|
||||||
|
const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) });
|
||||||
|
if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) {
|
||||||
|
configurationError('diagnostic page exceeds its 256 KiB budget');
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageBytes(page: Readonly<LocalReconciliationDiagnosticPage>): Buffer {
|
||||||
|
const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||||
|
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) {
|
||||||
|
configurationError('diagnostic page has an invalid size');
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageFile(
|
||||||
|
filePath: string,
|
||||||
|
bytes: Buffer,
|
||||||
|
uid: number,
|
||||||
|
allowedLinks: readonly number[],
|
||||||
|
label: string,
|
||||||
|
): fs.Stats {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
return configurationError(`${label} is unavailable`, error);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
(stat.mode & 0o777) !== 0o600 ||
|
||||||
|
!allowedLinks.includes(stat.nlink) ||
|
||||||
|
stat.size !== bytes.byteLength ||
|
||||||
|
!fs.readFileSync(filePath).equals(bytes)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} identity or content drifted`);
|
||||||
|
}
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directory: string): void {
|
||||||
|
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||||
|
try {
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStage(
|
||||||
|
stagePath: string,
|
||||||
|
bytes: Buffer,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
let created = false;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
stagePath,
|
||||||
|
fs.constants.O_WRONLY |
|
||||||
|
fs.constants.O_CREAT |
|
||||||
|
fs.constants.O_EXCL |
|
||||||
|
(fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
created = true;
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
const stat = fs.fstatSync(descriptor);
|
||||||
|
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
|
||||||
|
configurationError('diagnostic page stage identity drifted');
|
||||||
|
}
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.byteLength) {
|
||||||
|
const written = fs.writeSync(
|
||||||
|
descriptor,
|
||||||
|
bytes,
|
||||||
|
offset,
|
||||||
|
bytes.byteLength - offset,
|
||||||
|
);
|
||||||
|
if (written < 1) configurationError('diagnostic page write stalled');
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} catch (error) {
|
||||||
|
if (created) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(stagePath);
|
||||||
|
} catch {
|
||||||
|
// A failed cleanup leaves a deterministic fail-closed stage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
configurationError('diagnostic page stage cannot be written', error);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishLocalReconciliationDiagnosticPage(
|
||||||
|
outputPath: string,
|
||||||
|
page: Readonly<LocalReconciliationDiagnosticPage>,
|
||||||
|
uid: number,
|
||||||
|
): 'prepared' | 'existing' {
|
||||||
|
const parent = path.dirname(outputPath);
|
||||||
|
validatePrivateDirectory(parent, uid, 'diagnosticOutputParent');
|
||||||
|
const bytes = pageBytes(page);
|
||||||
|
const stagePath = path.join(
|
||||||
|
parent,
|
||||||
|
`.${path.basename(outputPath)}.ql3-review-page-stage`,
|
||||||
|
);
|
||||||
|
const existed = fs.existsSync(outputPath);
|
||||||
|
if (existed) pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page');
|
||||||
|
if (fs.existsSync(stagePath)) {
|
||||||
|
pageFile(stagePath, bytes, uid, [1, 2], 'diagnostic page stage');
|
||||||
|
} else if (!existed) {
|
||||||
|
writeStage(stagePath, bytes, uid);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(outputPath)) {
|
||||||
|
try {
|
||||||
|
fs.linkSync(stagePath, outputPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
configurationError('diagnostic page cannot be published', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncDirectory(parent);
|
||||||
|
}
|
||||||
|
const target = pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page');
|
||||||
|
if (fs.existsSync(stagePath)) {
|
||||||
|
const stage = pageFile(
|
||||||
|
stagePath,
|
||||||
|
bytes,
|
||||||
|
uid,
|
||||||
|
[1, 2],
|
||||||
|
'diagnostic page stage',
|
||||||
|
);
|
||||||
|
if (target.dev !== stage.dev || target.ino !== stage.ino) {
|
||||||
|
configurationError('diagnostic page stage identity drifted');
|
||||||
|
}
|
||||||
|
fs.unlinkSync(stagePath);
|
||||||
|
syncDirectory(parent);
|
||||||
|
}
|
||||||
|
pageFile(outputPath, bytes, uid, [1], 'diagnostic page');
|
||||||
|
return existed ? 'existing' : 'prepared';
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
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,
|
||||||
|
preflightPublishedFile,
|
||||||
|
publishExactFile,
|
||||||
|
validatePrivateDirectory,
|
||||||
|
} from '../../foundation/files';
|
||||||
|
import {
|
||||||
|
advanceLocalCutoverInstanceHead,
|
||||||
|
readLocalCutoverInstanceHead,
|
||||||
|
type LocalCutoverInstanceHead,
|
||||||
|
} from '../../cutover/instanceLineage';
|
||||||
|
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||||
|
import { readLocalReconciliationPlanTerminal } from '../planning/preparation';
|
||||||
|
import {
|
||||||
|
withLocalReconciliationSealedDatabase,
|
||||||
|
type LocalReconciliationSealedBundleReaderDependencies,
|
||||||
|
} from '../sealed-bundle/reader';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationReviewDiagnosticsCommand,
|
||||||
|
normalizeLocalReconciliationReviewPrepareCommand,
|
||||||
|
type LocalReconciliationReviewDiagnosticsCommand,
|
||||||
|
type LocalReconciliationReviewDiagnosticsResult,
|
||||||
|
type LocalReconciliationReviewPrepareCommand,
|
||||||
|
type LocalReconciliationReviewPrepareResult,
|
||||||
|
} from './contract';
|
||||||
|
import {
|
||||||
|
buildLocalReconciliationDiagnosticPage,
|
||||||
|
publishLocalReconciliationDiagnosticPage,
|
||||||
|
} from './diagnostics';
|
||||||
|
|
||||||
|
const INTENT_SCHEMA = 'qinglong3-local-reconciliation-review-intent';
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const MAX_REVIEWS = 64;
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewIntent {
|
||||||
|
readonly schema: typeof INTENT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_review_prepared';
|
||||||
|
readonly command: Readonly<LocalReconciliationReviewPrepareCommand>;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly cutoverId: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly activationDigest: string;
|
||||||
|
readonly bundleDigest: string;
|
||||||
|
readonly bundleFingerprintDigest: string;
|
||||||
|
readonly planReceiptDigest: string;
|
||||||
|
readonly plannedHeadDigest: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewDependencies
|
||||||
|
extends LocalReconciliationSealedBundleReaderDependencies {
|
||||||
|
readonly afterHeadPrepared?: () => void;
|
||||||
|
readonly beforeDiagnosticPublish?: () => void;
|
||||||
|
readonly afterDiagnosticPublish?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalReconciliationReviewPaths {
|
||||||
|
readonly root: string;
|
||||||
|
readonly staging: string;
|
||||||
|
readonly intent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(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 contents(value: unknown): string {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(left: string, right: string): boolean {
|
||||||
|
const relative = path.relative(left, right);
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localReconciliationReviewDirectory(
|
||||||
|
reviewRoot: string,
|
||||||
|
reviewId: string,
|
||||||
|
): string {
|
||||||
|
return path.join(reviewRoot, reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewPaths(
|
||||||
|
reviewRoot: string,
|
||||||
|
reviewId: string,
|
||||||
|
): Readonly<LocalReconciliationReviewPaths> {
|
||||||
|
const root = localReconciliationReviewDirectory(reviewRoot, reviewId);
|
||||||
|
return Object.freeze({
|
||||||
|
root,
|
||||||
|
staging: path.join(root, 'staging'),
|
||||||
|
intent: path.join(root, 'intent.json'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReviewDirectory(
|
||||||
|
reviewRoot: string,
|
||||||
|
reviewId: string,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationReviewPaths> {
|
||||||
|
const paths = reviewPaths(reviewRoot, reviewId);
|
||||||
|
const entries = fs.readdirSync(reviewRoot, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||||
|
configurationError('reconciliation review catalog contains drift');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.length >= MAX_REVIEWS && !fs.existsSync(paths.root)) {
|
||||||
|
configurationError('reconciliation review retention limit is reached');
|
||||||
|
}
|
||||||
|
ensurePrivateDirectory(paths.root, uid, 'reconciliationReviewDirectory');
|
||||||
|
ensurePrivateDirectory(paths.staging, uid, 'reconciliationReviewStaging');
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCatalog(paths: Readonly<LocalReconciliationReviewPaths>): void {
|
||||||
|
const allowed = new Set([
|
||||||
|
'intent.json',
|
||||||
|
'staging',
|
||||||
|
'.intent.json.ql3-deploy-stage',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
|
||||||
|
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
configurationError('reconciliation review root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fs.readdirSync(paths.staging).length !== 0) {
|
||||||
|
configurationError('reconciliation review staging contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIntent(
|
||||||
|
command: Readonly<LocalReconciliationReviewPrepareCommand>,
|
||||||
|
terminal: ReturnType<typeof readLocalReconciliationPlanTerminal>,
|
||||||
|
): Readonly<LocalReconciliationReviewIntent> {
|
||||||
|
if (
|
||||||
|
terminal.intent.command.options.deploymentRoot !==
|
||||||
|
command.options.deploymentRoot ||
|
||||||
|
terminal.intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||||
|
terminal.intent.command.options.planRoot !== command.options.planRoot ||
|
||||||
|
terminal.intent.command.options.allowRootService !==
|
||||||
|
command.options.allowRootService ||
|
||||||
|
terminal.plan.planId !== command.request.planId ||
|
||||||
|
terminal.plan.planDigest !== command.request.expectedPlanDigest ||
|
||||||
|
command.request.preparedAtMs < terminal.plan.committedAtMs
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review is detached from its plan');
|
||||||
|
}
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: INTENT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_review_prepared' as const,
|
||||||
|
command,
|
||||||
|
profile: terminal.plan.profile,
|
||||||
|
instanceId: terminal.intent.instanceId,
|
||||||
|
cutoverId: terminal.intent.cutoverId,
|
||||||
|
generation: terminal.intent.generation,
|
||||||
|
activationDigest: terminal.intent.activationDigest,
|
||||||
|
bundleDigest: terminal.plan.bundleDigest,
|
||||||
|
bundleFingerprintDigest: terminal.bundle.fingerprintDigest,
|
||||||
|
planReceiptDigest: terminal.receipt.receiptDigest,
|
||||||
|
plannedHeadDigest: command.request.expectedHeadDigest,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationReviewIntent(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationReviewIntent> {
|
||||||
|
const intent = object(value, 'reconciliation review intent');
|
||||||
|
exact(
|
||||||
|
intent,
|
||||||
|
[
|
||||||
|
'activationDigest',
|
||||||
|
'bundleDigest',
|
||||||
|
'bundleFingerprintDigest',
|
||||||
|
'command',
|
||||||
|
'cutoverId',
|
||||||
|
'generation',
|
||||||
|
'instanceId',
|
||||||
|
'planReceiptDigest',
|
||||||
|
'plannedHeadDigest',
|
||||||
|
'preparationDigest',
|
||||||
|
'profile',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'state',
|
||||||
|
],
|
||||||
|
'reconciliation review intent',
|
||||||
|
);
|
||||||
|
const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command);
|
||||||
|
const { preparationDigest, ...payload } = intent;
|
||||||
|
if (
|
||||||
|
intent.schema !== INTENT_SCHEMA ||
|
||||||
|
intent.schemaVersion !== 1 ||
|
||||||
|
intent.state !== 'reconciliation_review_prepared' ||
|
||||||
|
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
|
||||||
|
typeof intent.instanceId !== 'string' ||
|
||||||
|
intent.instanceId.length < 1 ||
|
||||||
|
intent.instanceId.length > 128 ||
|
||||||
|
typeof intent.cutoverId !== 'string' ||
|
||||||
|
intent.cutoverId.length < 1 ||
|
||||||
|
intent.cutoverId.length > 128 ||
|
||||||
|
!Number.isSafeInteger(intent.generation) ||
|
||||||
|
(intent.generation as number) < 1 ||
|
||||||
|
[
|
||||||
|
intent.activationDigest,
|
||||||
|
intent.bundleDigest,
|
||||||
|
intent.bundleFingerprintDigest,
|
||||||
|
intent.planReceiptDigest,
|
||||||
|
intent.plannedHeadDigest,
|
||||||
|
preparationDigest,
|
||||||
|
].some(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||||
|
) ||
|
||||||
|
cutoverDigest(payload) !== preparationDigest
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review intent drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...(intent as unknown as LocalReconciliationReviewIntent),
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationReviewIntent(
|
||||||
|
reviewRoot: string,
|
||||||
|
reviewId: string,
|
||||||
|
): Readonly<LocalReconciliationReviewIntent> {
|
||||||
|
return normalizeLocalReconciliationReviewIntent(
|
||||||
|
readPrivateLocalCommandFile(reviewPaths(reviewRoot, reviewId).intent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeadIdentity(
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
head.profile !== intent.profile ||
|
||||||
|
head.cutoverId !== intent.cutoverId ||
|
||||||
|
head.activationDigest !== intent.activationDigest ||
|
||||||
|
head.generation !== intent.generation
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation review instance head identity drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advancePreparedHead(
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalCutoverInstanceHead> {
|
||||||
|
return advanceLocalCutoverInstanceHead(
|
||||||
|
{
|
||||||
|
options: { deploymentRoot: intent.command.options.deploymentRoot },
|
||||||
|
request: {
|
||||||
|
cutoverId: intent.cutoverId,
|
||||||
|
profile: intent.profile,
|
||||||
|
instanceId: intent.instanceId,
|
||||||
|
expectedActivationDigest: intent.activationDigest,
|
||||||
|
requestedAtMs: intent.command.request.preparedAtMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uid,
|
||||||
|
'reconciliation_review_prepared',
|
||||||
|
intent.generation,
|
||||||
|
intent.preparationDigest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareLocalReconciliationReview(
|
||||||
|
input: unknown,
|
||||||
|
dependencies: LocalReconciliationReviewDependencies = {},
|
||||||
|
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||||
|
const command = normalizeLocalReconciliationReviewPrepareCommand(input);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||||
|
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||||
|
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||||
|
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||||
|
const terminal = readLocalReconciliationPlanTerminal(
|
||||||
|
command.options.planRoot,
|
||||||
|
command.request.planId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
const intent = buildIntent(command, terminal);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
validateHeadIdentity(head, intent);
|
||||||
|
if (
|
||||||
|
(head.state === 'reconciliation_planned' &&
|
||||||
|
(head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
head.sourceRecordDigest !== command.request.expectedPlanDigest)) ||
|
||||||
|
(head.state === 'reconciliation_review_prepared' &&
|
||||||
|
head.sourceRecordDigest !== intent.preparationDigest) ||
|
||||||
|
(head.state !== 'reconciliation_planned' &&
|
||||||
|
head.state !== 'reconciliation_review_prepared')
|
||||||
|
) {
|
||||||
|
configurationError('review prepare lost the planned instance head compare-and-swap');
|
||||||
|
}
|
||||||
|
const paths = ensureReviewDirectory(
|
||||||
|
command.options.reviewRoot,
|
||||||
|
command.request.reviewId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
const serialized = contents(intent);
|
||||||
|
preflightPublishedFile(
|
||||||
|
paths.intent,
|
||||||
|
serialized,
|
||||||
|
0o600,
|
||||||
|
identity.uid,
|
||||||
|
'reconciliation review intent',
|
||||||
|
);
|
||||||
|
const nextHead =
|
||||||
|
head.state === 'reconciliation_review_prepared'
|
||||||
|
? head
|
||||||
|
: advancePreparedHead(intent, identity.uid);
|
||||||
|
dependencies.afterHeadPrepared?.();
|
||||||
|
const status = publishExactFile(
|
||||||
|
paths.intent,
|
||||||
|
serialized,
|
||||||
|
0o600,
|
||||||
|
identity.uid,
|
||||||
|
'reconciliation review intent',
|
||||||
|
);
|
||||||
|
validateCatalog(paths);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
operation: command.operation,
|
||||||
|
status,
|
||||||
|
state: 'reconciliation_review_prepared' as const,
|
||||||
|
reviewId: command.request.reviewId,
|
||||||
|
preparationDigest: intent.preparationDigest,
|
||||||
|
instanceHeadDigest: nextHead.headDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDiagnosticsBinding(
|
||||||
|
command: Readonly<LocalReconciliationReviewDiagnosticsCommand>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
|
||||||
|
intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||||
|
intent.command.options.planRoot !== command.options.planRoot ||
|
||||||
|
intent.command.options.reviewRoot !== command.options.reviewRoot ||
|
||||||
|
intent.command.options.allowRootService !== command.options.allowRootService ||
|
||||||
|
intent.command.request.reviewId !== command.request.reviewId ||
|
||||||
|
intent.preparationDigest !== command.request.expectedPreparationDigest
|
||||||
|
) {
|
||||||
|
configurationError('review diagnostics are detached from preparation');
|
||||||
|
}
|
||||||
|
const roots = [
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
command.options.captureRoot,
|
||||||
|
command.options.planRoot,
|
||||||
|
command.options.reviewRoot,
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
roots.some(
|
||||||
|
(root) =>
|
||||||
|
overlaps(root, command.request.outputPath) ||
|
||||||
|
overlaps(command.request.outputPath, root),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
configurationError('diagnostic output must be outside authority roots');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLocalReconciliationReviewDiagnostics(
|
||||||
|
input: unknown,
|
||||||
|
dependencies: LocalReconciliationReviewDependencies = {},
|
||||||
|
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||||
|
const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||||
|
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||||
|
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||||
|
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||||
|
const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId);
|
||||||
|
validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory');
|
||||||
|
validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging');
|
||||||
|
const intent = readLocalReconciliationReviewIntent(
|
||||||
|
command.options.reviewRoot,
|
||||||
|
command.request.reviewId,
|
||||||
|
);
|
||||||
|
validateDiagnosticsBinding(command, intent);
|
||||||
|
const terminal = readLocalReconciliationPlanTerminal(
|
||||||
|
command.options.planRoot,
|
||||||
|
intent.command.request.planId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
terminal.plan.planDigest !== intent.command.request.expectedPlanDigest ||
|
||||||
|
terminal.plan.bundleDigest !== intent.bundleDigest ||
|
||||||
|
terminal.receipt.receiptDigest !== intent.planReceiptDigest ||
|
||||||
|
terminal.bundle.fingerprintDigest !== intent.bundleFingerprintDigest
|
||||||
|
) {
|
||||||
|
configurationError('review diagnostics lost the terminal plan binding');
|
||||||
|
}
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
validateHeadIdentity(head, intent);
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_review_prepared' ||
|
||||||
|
head.sourceRecordDigest !== intent.preparationDigest
|
||||||
|
) {
|
||||||
|
configurationError('review diagnostics lost the prepared instance head');
|
||||||
|
}
|
||||||
|
const page = withLocalReconciliationSealedDatabase(
|
||||||
|
terminal.bundle,
|
||||||
|
command.request.database,
|
||||||
|
identity.uid,
|
||||||
|
dependencies,
|
||||||
|
(client) =>
|
||||||
|
buildLocalReconciliationDiagnosticPage(client, {
|
||||||
|
reviewId: intent.command.request.reviewId,
|
||||||
|
planId: intent.command.request.planId,
|
||||||
|
planDigest: terminal.plan.planDigest,
|
||||||
|
preparationDigest: intent.preparationDigest,
|
||||||
|
bundle: terminal.bundle,
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (page === null) {
|
||||||
|
configurationError('manual-required SQLite topology has no diagnostic page');
|
||||||
|
}
|
||||||
|
const currentHead = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
currentHead.headDigest !== head.headDigest ||
|
||||||
|
currentHead.state !== 'reconciliation_review_prepared' ||
|
||||||
|
currentHead.sourceRecordDigest !== intent.preparationDigest
|
||||||
|
) {
|
||||||
|
configurationError('review diagnostics instance head changed while reading');
|
||||||
|
}
|
||||||
|
dependencies.beforeDiagnosticPublish?.();
|
||||||
|
const status = publishLocalReconciliationDiagnosticPage(
|
||||||
|
command.request.outputPath,
|
||||||
|
page,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
dependencies.afterDiagnosticPublish?.();
|
||||||
|
validateCatalog(paths);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
operation: command.operation,
|
||||||
|
status,
|
||||||
|
state: 'reconciliation_review_prepared' as const,
|
||||||
|
reviewId: command.request.reviewId,
|
||||||
|
pageDigest: page.pageDigest,
|
||||||
|
recordCount: page.recordCount,
|
||||||
|
complete: page.complete,
|
||||||
|
nextOffset: page.nextOffset,
|
||||||
|
instanceHeadDigest: currentHead.headDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareLocalReconciliationReviewCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||||
|
return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLocalReconciliationReviewDiagnosticsCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||||
|
return writeLocalReconciliationReviewDiagnostics(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-1
@@ -751,7 +751,9 @@ function replayResult(
|
|||||||
(head.state === 'reconciliation_capture_prepared' ||
|
(head.state === 'reconciliation_capture_prepared' ||
|
||||||
head.state === 'reconciliation_captured' ||
|
head.state === 'reconciliation_captured' ||
|
||||||
head.state === 'reconciliation_plan_prepared' ||
|
head.state === 'reconciliation_plan_prepared' ||
|
||||||
head.state === 'reconciliation_planned');
|
head.state === 'reconciliation_planned' ||
|
||||||
|
head.state === 'reconciliation_review_prepared' ||
|
||||||
|
head.state === 'reconciliation_reviewed');
|
||||||
if (
|
if (
|
||||||
record.actionId !== intent.actionId ||
|
record.actionId !== intent.actionId ||
|
||||||
record.intentDigest !== intent.intentDigest ||
|
record.intentDigest !== intent.intentDigest ||
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const {
|
|||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
} = require('../dist/deployment/localDeployment.js');
|
} = require('../dist/deployment/localDeployment.js');
|
||||||
const {
|
const {
|
||||||
normalizeLocalReconciliationCaptureManifest,
|
normalizeLocalReconciliationCaptureManifest,
|
||||||
@@ -688,6 +690,74 @@ function preparedPlan(t, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preparedReview(t, options = {}) {
|
||||||
|
const state = preparedPlan(t, options);
|
||||||
|
const planned = commitLocalReconciliationPlan(state.planCommitCommand);
|
||||||
|
const root = path.dirname(state.captureRoot);
|
||||||
|
const reviewRoot = path.join(root, `review-root-${options.reviewSuffix ?? '1'}`);
|
||||||
|
const diagnosticRoot = path.join(
|
||||||
|
root,
|
||||||
|
`diagnostic-root-${options.reviewSuffix ?? '1'}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(reviewRoot, { mode: 0o700 });
|
||||||
|
fs.mkdirSync(diagnosticRoot, { mode: 0o700 });
|
||||||
|
const reviewCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.review.prepare',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
captureRoot: state.captureRoot,
|
||||||
|
planRoot: state.planRoot,
|
||||||
|
reviewRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
reviewId:
|
||||||
|
options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
||||||
|
planId: state.prepareCommand.request.planId,
|
||||||
|
expectedPlanDigest: planned.planDigest,
|
||||||
|
expectedHeadDigest: planned.instanceHeadDigest,
|
||||||
|
preparedAtMs: 8_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
planned,
|
||||||
|
reviewRoot,
|
||||||
|
diagnosticRoot,
|
||||||
|
reviewCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticCommand(
|
||||||
|
state,
|
||||||
|
prepared,
|
||||||
|
{
|
||||||
|
database = 'legacy',
|
||||||
|
domain = 'automation',
|
||||||
|
factKind = 'table',
|
||||||
|
offset = 0,
|
||||||
|
limit = 64,
|
||||||
|
outputName = 'diagnostic-page.json',
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.review.diagnostics',
|
||||||
|
options: state.reviewCommand.options,
|
||||||
|
request: {
|
||||||
|
reviewId: state.reviewCommand.request.reviewId,
|
||||||
|
expectedPreparationDigest: prepared.preparationDigest,
|
||||||
|
database,
|
||||||
|
domain,
|
||||||
|
factKind,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
outputPath: path.join(state.diagnosticRoot, outputName),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||||
const source =
|
const source =
|
||||||
mode === 'main_only_immutable'
|
mode === 'main_only_immutable'
|
||||||
@@ -1547,6 +1617,234 @@ test('plan prepare recovers head response loss and CLI verify stays content-free
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('review prepare establishes one replayable fence and blocks rollback', (t) => {
|
||||||
|
const state = preparedReview(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000301',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000311',
|
||||||
|
});
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
prepareLocalReconciliationReview(state.reviewCommand, {
|
||||||
|
afterHeadPrepared() {
|
||||||
|
throw new Error('review prepare response loss');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/review prepare response loss/,
|
||||||
|
);
|
||||||
|
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||||
|
assert.equal(prepared.status, 'prepared');
|
||||||
|
assert.equal(prepared.state, 'reconciliation_review_prepared');
|
||||||
|
const replay = prepareLocalReconciliationReview(state.reviewCommand);
|
||||||
|
assert.equal(replay.status, 'existing');
|
||||||
|
assert.equal(replay.preparationDigest, prepared.preparationDigest);
|
||||||
|
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
state.deploymentRoot,
|
||||||
|
state.command.request.instanceId,
|
||||||
|
state.uid,
|
||||||
|
);
|
||||||
|
assert.equal(head.state, 'reconciliation_review_prepared');
|
||||||
|
assert.equal(head.sourceRecordDigest, prepared.preparationDigest);
|
||||||
|
|
||||||
|
const competing = structuredClone(state.reviewCommand);
|
||||||
|
competing.request.reviewId = '00000000-0000-4000-8000-000000000312';
|
||||||
|
competing.options.reviewRoot = path.join(
|
||||||
|
path.dirname(state.reviewRoot),
|
||||||
|
'review-root-competing',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(competing.options.reviewRoot, { mode: 0o700 });
|
||||||
|
assert.throws(
|
||||||
|
() => prepareLocalReconciliationReview(competing),
|
||||||
|
/compare-and-swap/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
advanceLocalCutoverInstanceHead(
|
||||||
|
state.identity,
|
||||||
|
state.uid,
|
||||||
|
'rollback_prepared',
|
||||||
|
1,
|
||||||
|
'f'.repeat(64),
|
||||||
|
),
|
||||||
|
/transition is invalid/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
advanceLocalCutoverInstanceHead(
|
||||||
|
state.identity,
|
||||||
|
state.uid,
|
||||||
|
'target_active',
|
||||||
|
2,
|
||||||
|
'e'.repeat(64),
|
||||||
|
),
|
||||||
|
/transition is invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review diagnostics publish one private exact page without changing assets', (t) => {
|
||||||
|
const state = preparedReview(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000321',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000322',
|
||||||
|
});
|
||||||
|
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||||
|
const assets = fs.readdirSync(capturePath(state, 'assets')).map((name) => ({
|
||||||
|
name,
|
||||||
|
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
||||||
|
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
||||||
|
}));
|
||||||
|
const command = diagnosticCommand(state, prepared);
|
||||||
|
const opens = [];
|
||||||
|
const result = writeLocalReconciliationReviewDiagnostics(command, {
|
||||||
|
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
||||||
|
opens.push({ kind, mode, cacheKiB });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(opens, [
|
||||||
|
{ kind: 'legacy', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||||
|
]);
|
||||||
|
assert.equal(result.status, 'prepared');
|
||||||
|
assert.equal(result.recordCount, 1);
|
||||||
|
assert.equal(result.complete, true);
|
||||||
|
assert.equal(result.nextOffset, null);
|
||||||
|
assert.equal(JSON.stringify(result).includes(command.request.outputPath), false);
|
||||||
|
assert.equal(JSON.stringify(result).includes('Crontabs'), false);
|
||||||
|
const pageText = fs.readFileSync(command.request.outputPath, 'utf8');
|
||||||
|
const page = JSON.parse(pageText);
|
||||||
|
assert.equal(page.records[0].name, 'Crontabs');
|
||||||
|
assert.equal(page.records[0].rowCount, '1');
|
||||||
|
assert.equal(page.records[0].decisionRequirement, 'required');
|
||||||
|
assert.equal(pageText.includes('0 0 * * *'), false);
|
||||||
|
assert.equal(pageText.includes('private-value'), false);
|
||||||
|
assert.equal(fs.statSync(command.request.outputPath).mode & 0o777, 0o600);
|
||||||
|
assert.equal(
|
||||||
|
writeLocalReconciliationReviewDiagnostics(command).status,
|
||||||
|
'existing',
|
||||||
|
);
|
||||||
|
for (const before of assets) {
|
||||||
|
const assetPath = capturePath(state, `assets/${before.name}`);
|
||||||
|
const after = fs.statSync(assetPath, { bigint: true });
|
||||||
|
assert.equal(fs.readFileSync(assetPath).equals(before.bytes), true);
|
||||||
|
assert.equal(after.mtimeNs, before.stat.mtimeNs);
|
||||||
|
assert.equal(after.ctimeNs, before.stat.ctimeNs);
|
||||||
|
assert.equal(after.mode, before.stat.mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review diagnostics keep secret and unknown facts blocked and row-free', (t) => {
|
||||||
|
const state = preparedReview(t, {
|
||||||
|
unknownTargetTable: true,
|
||||||
|
planId: '00000000-0000-4000-8000-000000000331',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000332',
|
||||||
|
});
|
||||||
|
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||||
|
const secretCommand = diagnosticCommand(state, prepared, {
|
||||||
|
domain: 'secret_and_config',
|
||||||
|
outputName: 'secret.json',
|
||||||
|
});
|
||||||
|
writeLocalReconciliationReviewDiagnostics(secretCommand);
|
||||||
|
const secretText = fs.readFileSync(secretCommand.request.outputPath, 'utf8');
|
||||||
|
const secret = JSON.parse(secretText);
|
||||||
|
assert.equal(secret.records[0].name, 'Envs');
|
||||||
|
assert.equal(secret.records[0].decisionRequirement, 'blocked');
|
||||||
|
assert.equal(secret.records[0].reason, 'secret_custody_required');
|
||||||
|
assert.equal(secretText.includes('TOKEN'), false);
|
||||||
|
assert.equal(secretText.includes('private-value'), false);
|
||||||
|
|
||||||
|
const unknownCommand = diagnosticCommand(state, prepared, {
|
||||||
|
database: 'target',
|
||||||
|
domain: 'unknown',
|
||||||
|
outputName: 'unknown.json',
|
||||||
|
});
|
||||||
|
writeLocalReconciliationReviewDiagnostics(unknownCommand);
|
||||||
|
const unknown = JSON.parse(
|
||||||
|
fs.readFileSync(unknownCommand.request.outputPath, 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(unknown.records[0].name, 'UnreviewedFacts');
|
||||||
|
assert.equal(unknown.records[0].rowCount, null);
|
||||||
|
assert.equal(unknown.records[0].decisionRequirement, 'blocked');
|
||||||
|
assert.equal(unknown.records[0].reason, 'unknown_schema');
|
||||||
|
|
||||||
|
const overlapping = structuredClone(unknownCommand);
|
||||||
|
overlapping.request.outputPath = path.join(state.reviewRoot, 'leak.json');
|
||||||
|
assert.throws(
|
||||||
|
() => writeLocalReconciliationReviewDiagnostics(overlapping),
|
||||||
|
/outside authority roots/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review diagnostics page at sixty-four and CLI output stays content-free', (t) => {
|
||||||
|
const initializeDatabases = (paths) => {
|
||||||
|
planningDatabaseInitializer()(paths);
|
||||||
|
const target = new DatabaseSync(paths.targetDatabasePath);
|
||||||
|
for (let index = 0; index < 70; index += 1) {
|
||||||
|
target.exec(
|
||||||
|
`CREATE TABLE "QingLong3TaskDefinitionExtra${String(index).padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}" (id INTEGER PRIMARY KEY)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
target.close();
|
||||||
|
fs.chmodSync(paths.targetDatabasePath, 0o600);
|
||||||
|
};
|
||||||
|
const state = preparedReview(t, {
|
||||||
|
initializeDatabases,
|
||||||
|
planId: '00000000-0000-4000-8000-000000000341',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000342',
|
||||||
|
});
|
||||||
|
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||||
|
const firstCommand = diagnosticCommand(state, prepared, {
|
||||||
|
database: 'target',
|
||||||
|
domain: 'automation',
|
||||||
|
factKind: 'schema_object',
|
||||||
|
outputName: 'page-1.json',
|
||||||
|
});
|
||||||
|
const first = writeLocalReconciliationReviewDiagnostics(firstCommand);
|
||||||
|
assert.equal(first.recordCount, 64);
|
||||||
|
assert.equal(first.complete, false);
|
||||||
|
assert.equal(first.nextOffset, 64);
|
||||||
|
const secondCommand = diagnosticCommand(state, prepared, {
|
||||||
|
database: 'target',
|
||||||
|
domain: 'automation',
|
||||||
|
factKind: 'schema_object',
|
||||||
|
offset: first.nextOffset,
|
||||||
|
outputName: 'page-2.json',
|
||||||
|
});
|
||||||
|
const second = writeLocalReconciliationReviewDiagnostics(secondCommand);
|
||||||
|
assert.equal(second.recordCount, 7);
|
||||||
|
assert.equal(second.complete, true);
|
||||||
|
assert.equal(second.nextOffset, null);
|
||||||
|
|
||||||
|
const cliCommand = diagnosticCommand(state, prepared, {
|
||||||
|
database: 'target',
|
||||||
|
domain: 'automation',
|
||||||
|
factKind: 'table',
|
||||||
|
limit: 1,
|
||||||
|
outputName: 'cli-page.json',
|
||||||
|
});
|
||||||
|
const commandPath = path.join(state.deploymentRoot, 'review-diagnostic.json');
|
||||||
|
fs.writeFileSync(commandPath, `${JSON.stringify(cliCommand)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const cli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-review-diagnostics',
|
||||||
|
'--command-file',
|
||||||
|
commandPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(cli.status, 0, cli.stderr);
|
||||||
|
const output = JSON.parse(cli.stdout);
|
||||||
|
assert.equal(output.recordCount, 1);
|
||||||
|
assert.equal(cli.stdout.includes(state.reviewRoot), false);
|
||||||
|
assert.equal(cli.stdout.includes(state.diagnosticRoot), false);
|
||||||
|
assert.equal(cli.stdout.includes('QingLong3TaskDefinitions'), false);
|
||||||
|
assert.equal(cli.stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'real stopped Docker target produces an independently verified bundle',
|
'real stopped Docker target produces an independently verified bundle',
|
||||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 146,
|
sourceFiles: 149,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 145,
|
nestedSourceFiles: 148,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user