mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 20:15:19 +08:00
feat(local): preserve terminal run history
This commit is contained in:
@@ -6,10 +6,28 @@
|
|||||||
- 目标版本:QingLong 3.x
|
- 目标版本:QingLong 3.x
|
||||||
- 作者:QingLong Maintainers
|
- 作者:QingLong Maintainers
|
||||||
- 创建日期:2026-07-17
|
- 创建日期:2026-07-17
|
||||||
- 最后更新:2026-08-22
|
- 最后更新:2026-08-23
|
||||||
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
||||||
|
|
||||||
最新增量证据(2026-08-22):
|
最新增量证据(2026-08-23):
|
||||||
|
|
||||||
|
- D-396/ADR-0490(进行中):Run History 不再只有永久 `manual_external`,但也没有被错误实现为 Legacy 日志到 3.0 Run ledger 的回灌。
|
||||||
|
新的 Local adapter 以 ADR-0482 sealed capture bundle 作为 append-only 保全资产:Legacy history 必须逐事实选择 `retain_both`,Target history
|
||||||
|
必须选择 `retain_target`;receipt 只绑定 signed review、application、bundle fingerprint、领域 inventory 与有界 fact counts,不保存表名、Run ID、
|
||||||
|
状态值、日志、路径或 row body。Target 只有在所有 Run 已 `succeeded|failed|cancelled|timed_out` 且有完成时间、没有 active Attempt、所有
|
||||||
|
StepRun 已终态时才从 `blocked/historical_integrity_required` 提升为 `required/historical_preservation_required`;active、waiting、retry、lost、
|
||||||
|
缺列或 schema drift 继续失败关闭,人工 decision 不能升级。
|
||||||
|
|
||||||
|
实现位于既有 `local-owner-cli/deployment/reconciliation/application/run-history/{contract,evidence,coordinator}`,新增短生命周期
|
||||||
|
`reconciliation-run-history-preserve|verify`,不新增 instance state、package、dependency、binary、SQL、daemon 或 `src/` 根平铺。preservation
|
||||||
|
receipt no-replace 发布并封为 `0400/0500`,绑定 exact application/plan/head;跨领域 completion 以兼容 v1 的 schema v2 消费
|
||||||
|
`run_history_preservation`,`adapterCount` 可为 `0|1|2`,因此 Automation 与 Run History 可以同时被证明而不产生 adapter 状态组合爆炸。
|
||||||
|
当前聚焦 Run History `2/2`、旧 completion v1 `2/2`,已覆盖 publication/seal response loss、active Run、review escalation、receipt tamper、v2
|
||||||
|
completion 与 content-free CLI。完整 reconciliation 为 `48/46/2/0`,Local Owner 为 `273/266/7/0`,tracked backend 为
|
||||||
|
`1561/1559/2/0`,18-package clean build/test 为 `2919/2897/22/0`;package/dependency/source boundary、service-manager bridge、Edge import
|
||||||
|
与十四档 Local artifact audit 全部 compatible。workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner
|
||||||
|
为 `175/174/1`(source/nested/root binary entry);基础 Edge/Standalone 制品仍为 `2,611,978 / 2,612,056 bytes`、319 files、58 modules,
|
||||||
|
一次性 Owner authority 未进入低资源常驻 runtime。远程 CI 尚未验证,ADR-0490 保持 Proposed。
|
||||||
|
|
||||||
- D-393/ADR-0486/ADR-0487(进行中):首个 Automation adapter 已先建立独立的有界逐行 plan fence,而没有提前取得 DML authority。既有
|
- D-393/ADR-0486/ADR-0487(进行中):首个 Automation adapter 已先建立独立的有界逐行 plan fence,而没有提前取得 DML authority。既有
|
||||||
Legacy Crontab classifier 被复用于 exact sealed Legacy source;每行只记录 source/candidate digest、classification/reasons、proposed
|
Legacy Crontab classifier 被复用于 exact sealed Legacy source;每行只记录 source/candidate digest、classification/reasons、proposed
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# ADR-0490:Run History 终态保全与跨领域完成证明
|
||||||
|
|
||||||
|
- 状态:Proposed(D-396 实现与本地完整门禁已完成,等待远程 CI)
|
||||||
|
- 日期:2026-08-23
|
||||||
|
- 决策:D-396
|
||||||
|
- 关联:ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0488、ADR-0489
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
QingLong 2.x 的 `CrontabStats`、`Logs` 与 QingLong 3.0 的 `Runs`、`RunAttempts`、`RunEvents`、`StepRuns` 等对象都属于运行历史,但两代模型并不具备可证明的一一映射。把旧统计或日志行直接插入 3.0 Run ledger 会伪造 Run identity、event sequence、Attempt/Step 关系与执行来源;用 target 覆盖 Legacy 又会违反 append-only 历史边界。
|
||||||
|
|
||||||
|
D-391 因而把 Run History 全部标为 blocked,D-394 completion 也只能拒绝该领域。这个保守默认能避免数据损坏,却使一个已经停稳、只要求保留两侧历史的迁移永远不能形成完成证明。D-396 必须提供真正可完成、但不冒充历史转换的领域 adapter。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. Run History adapter 是保全证明,不是数据导入器
|
||||||
|
|
||||||
|
Legacy 与 target 的历史继续保存在 ADR-0482 已封存的 capture bundle 中。adapter 不执行 INSERT、UPDATE、DELETE、checkpoint、日志复制、格式转换或外部上传,也不把旧行回灌到 3.0 Run ledger。
|
||||||
|
|
||||||
|
合法裁决固定为:
|
||||||
|
|
||||||
|
- Legacy Run History 的每个 required fact 必须选择 `retain_both / preserve_both`;
|
||||||
|
- Target Run History 的每个 required fact 必须选择 `retain_target / preserve_target`;
|
||||||
|
- `adopt_legacy`、`exclude_legacy`、`defer`、`manual_external` 或任意 blocked fact 都不能形成保全证明。
|
||||||
|
|
||||||
|
receipt 只绑定 bundle、review、application、领域 inventory 与 fact counts,不保存表名、Run ID、状态值、日志、路径或 row body。
|
||||||
|
|
||||||
|
### 2. 只有确定终态的 target history 可被提升为 required
|
||||||
|
|
||||||
|
Legacy capture 已由 stopped source fence 固定,因此其 Run History 可进入 `historical_preservation_required`。Target 必须从 exact sealed SQLite 重新证明:
|
||||||
|
|
||||||
|
- `Runs` 存在 `status` 与 `finished_at_ms`,所有 Run 只能是 `succeeded|failed|cancelled|timed_out`,且具备完成时间;
|
||||||
|
- 若存在 `RunAttempts`,不能有 `claimed|starting|running` Attempt;
|
||||||
|
- 若存在 `StepRuns`,所有 Step 只能是 `succeeded|failed|skipped|cancelled|timed_out`,且具备完成时间;
|
||||||
|
- 缺少 canonical 列、active/waiting/retry/lost Run、active Attempt、pending/waiting/running/lost Step 或查询漂移继续保持 `blocked / historical_integrity_required`。
|
||||||
|
|
||||||
|
该判断同时用于 diagnostics、review commit 和 adapter replay;operator 不能通过修改私有 decision file 把 active Run 提升为可保全。
|
||||||
|
|
||||||
|
### 3. 新增独立短生命周期 preserve/verify 命令
|
||||||
|
|
||||||
|
既有 `ql3-local-deploy` 增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local.deployment.reconciliation.run-history.preserve
|
||||||
|
local.deployment.reconciliation.run-history.verify
|
||||||
|
```
|
||||||
|
|
||||||
|
preserve 重新打开 sealed application、plan、bundle 与 owner-private decision file,重放全部 canonical facts,并要求 review authorization 的 decision file digest/count 完全一致。它只允许 Run History domain 为 `adapter_required`,并绑定 exact `reconciliation_application_planned` 或 `reconciliation_automation_applied` source head。
|
||||||
|
|
||||||
|
每个 preservation directory 最多只含 `receipt.json`,以 no-replace publication、file/directory fsync 和 exact replay 收敛,最终文件/目录权限为 `0400/0500`;catalog 上限 64。receipt 发布或封存后的 response loss 可由相同命令继续完成,不覆盖冲突证据。
|
||||||
|
|
||||||
|
preserve 不增加 instance head 状态。它本身不授予 restart;若在发布期间另一个 adapter 推进 head,该证明会因 source-head digest 脱离而不能被 completion 消费,调用方必须在新的终态 head 上使用新 preservation ID 重新证明。这避免了多个独立 adapter 状态的组合爆炸。
|
||||||
|
|
||||||
|
### 4. Completion schema v2 消费第二种 adapter evidence
|
||||||
|
|
||||||
|
completion command/receipt 保留 v1 兼容语义;只有携带 Run History preservation 时使用 schema v2。固定八领域证据新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
run_history + adapter_required + run_history_preservation
|
||||||
|
```
|
||||||
|
|
||||||
|
completion 重新验证 sealed preservation、原 decision stream、bundle fingerprint、application plan 与 source head;调用方只能提交 preservation ID 和 expected digest,不能自报 domain evidence。`adapterCount` 从 `0|1` 扩展为 `0|1|2`,允许 Automation apply 与 Run History preservation 同时作为已证明 adapter。
|
||||||
|
|
||||||
|
完成时间不得早于 application commit、Automation apply 或 history preservation 中任一最新证据。v1 receipt 仍按原规则读取和验证;v2 receipt 必须实际包含 Run History preservation,不能用版本号绕过领域证明。
|
||||||
|
|
||||||
|
### 5. 低资源与集群边界
|
||||||
|
|
||||||
|
实现内聚于既有 `@qinglong/local-owner-cli/src/deployment/reconciliation/application/run-history/`,不新增 workspace package、production dependency、binary、daemon、timer、watcher、listener、socket、SQL migration 或 `src/` 根平铺。
|
||||||
|
|
||||||
|
Edge/Standalone 每次最多打开一个 sealed SQLite handle,状态判定均为 `LIMIT 1`,decision file 继续使用 64 KiB 流式缓冲;长期新增资产只有一个小型 content-free receipt,不复制数据库或日志。
|
||||||
|
|
||||||
|
本 ADR 只定义 Local SQLite 保全。Cluster/PostgreSQL 的 Run History 必须使用数据库 snapshot、事务一致性、租约/运行收敛与 HA retention 的独立证明,不能复用本机路径或把 Local capture bundle 当成集群历史 authority。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 把 Legacy 统计和日志导入 3.0 Run ledger
|
||||||
|
|
||||||
|
拒绝。没有 Run/Attempt/Event/Step identity 与顺序证明,导入会制造看似完整但语义虚假的运行记录。
|
||||||
|
|
||||||
|
### 只保存 source database 的 SHA-256
|
||||||
|
|
||||||
|
拒绝。整库 hash 会被其他 adapter 对无关表的合法写入破坏,也不能表达签名的 append-only 保留选择。D-396 绑定 sealed bundle 与 Run History domain inventory。
|
||||||
|
|
||||||
|
### 每增加一个 adapter 就新增 instance head 状态
|
||||||
|
|
||||||
|
拒绝。八领域会产生顺序与组合爆炸。无 DML 的 history proof 绑定当前 source head,最终由跨领域 completion 一次 CAS。
|
||||||
|
|
||||||
|
### 把 active/lost Run 当成历史
|
||||||
|
|
||||||
|
拒绝。`running|waiting_approval|retry_wait|lost` 都可能仍需执行、恢复或人工裁决,封存为完成历史会丢失运行 authority。
|
||||||
|
|
||||||
|
## 验收条件
|
||||||
|
|
||||||
|
1. terminal Legacy/Target history 从 blocked 精确提升为 required;active/inconclusive target history 始终 blocked。
|
||||||
|
2. 只有 `legacy retain_both + target retain_target` 的完整 signed canonical decision stream 可发布 receipt。
|
||||||
|
3. receipt 不包含名称、路径、Run ID、状态值、日志或 row body,且收敛为 `0400/0500`。
|
||||||
|
4. receipt publication/seal response loss exact replay;替换、篡改、额外 catalog、stale head 与 decision drift 全部失败关闭。
|
||||||
|
5. completion v2 重新验证 preservation 并生成固定八领域 evidence;completion v1 保持兼容。
|
||||||
|
6. preserve/verify 无 SQLite DML、数据库复制、service、Docker、network 或后台对象。
|
||||||
|
7. 聚焦、完整 Local Owner、tracked backend、18-package、package/dependency/source boundary、artifact/Edge import 与远程 CI 全部通过后,本 ADR 才能转为 Accepted。
|
||||||
|
|
||||||
|
## 当前证据
|
||||||
|
|
||||||
|
- 聚焦 Run History:`2/2`,覆盖终态 preserve/verify、两处 response-loss replay、v2 completion、CLI content-free、active Run blocked、人工越权拒绝与 sealed receipt 篡改。
|
||||||
|
- 既有 completion v1 聚焦回归:`2/2`。
|
||||||
|
- 完整 reconciliation:`48 total / 46 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner:`273 / 266 / 7 / 0`。
|
||||||
|
- tracked backend:`1561 total / 1559 pass / 2 conditional skip / 0 fail`;18-package clean build/test:`2919 / 2897 / 22 / 0`。
|
||||||
|
- package/dependency/source boundary、service-manager bridge import、Edge import 与十四档 Local artifact audit 全部 compatible;workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner 为 `175 source / 174 nested / 1 root binary entry`。
|
||||||
|
- 基础 Edge/Standalone 制品保持 `2,611,978 / 2,612,056 bytes`、319 files、58 loaded modules;本阶段未把一次性 Owner authority 带入常驻小设备 runtime。
|
||||||
|
- 远程 CI 尚未验证,因此本 ADR 仍为 Proposed。
|
||||||
@@ -154,6 +154,13 @@ import {
|
|||||||
verifyLocalReconciliationAutomationApply,
|
verifyLocalReconciliationAutomationApply,
|
||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
} from './reconciliation/application/automation/applyCoordinator';
|
} from './reconciliation/application/automation/applyCoordinator';
|
||||||
|
import {
|
||||||
|
preserveLocalReconciliationRunHistory,
|
||||||
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
|
readLocalReconciliationRunHistoryTerminal,
|
||||||
|
verifyLocalReconciliationRunHistory,
|
||||||
|
verifyLocalReconciliationRunHistoryCommandFile,
|
||||||
|
} from './reconciliation/application/run-history/coordinator';
|
||||||
import {
|
import {
|
||||||
completeLocalReconciliation,
|
completeLocalReconciliation,
|
||||||
completeLocalReconciliationCommandFile,
|
completeLocalReconciliationCommandFile,
|
||||||
@@ -199,6 +206,11 @@ export {
|
|||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
rollbackLocalReconciliationAutomationApply,
|
rollbackLocalReconciliationAutomationApply,
|
||||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
preserveLocalReconciliationRunHistory,
|
||||||
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
|
readLocalReconciliationRunHistoryTerminal,
|
||||||
|
verifyLocalReconciliationRunHistory,
|
||||||
|
verifyLocalReconciliationRunHistoryCommandFile,
|
||||||
completeLocalReconciliation,
|
completeLocalReconciliation,
|
||||||
completeLocalReconciliationCommandFile,
|
completeLocalReconciliationCommandFile,
|
||||||
verifyLocalReconciliationCompletion,
|
verifyLocalReconciliationCompletion,
|
||||||
@@ -212,6 +224,8 @@ export {
|
|||||||
type LocalReconciliationCompletionAutomationBinding,
|
type LocalReconciliationCompletionAutomationBinding,
|
||||||
type LocalReconciliationCompletionAutomationOptions,
|
type LocalReconciliationCompletionAutomationOptions,
|
||||||
type LocalReconciliationCompletionOptions,
|
type LocalReconciliationCompletionOptions,
|
||||||
|
type LocalReconciliationCompletionRunHistoryBinding,
|
||||||
|
type LocalReconciliationCompletionRunHistoryOptions,
|
||||||
type LocalReconciliationCompletionResult,
|
type LocalReconciliationCompletionResult,
|
||||||
type LocalReconciliationCompletionVerifyCommand,
|
type LocalReconciliationCompletionVerifyCommand,
|
||||||
} from './reconciliation/completion/contract';
|
} from './reconciliation/completion/contract';
|
||||||
@@ -234,6 +248,23 @@ export {
|
|||||||
} from './reconciliation/application/automation/applyContract';
|
} from './reconciliation/application/automation/applyContract';
|
||||||
export { type LocalReconciliationAutomationApplyDependencies } from './reconciliation/application/automation/applyCoordinator';
|
export { type LocalReconciliationAutomationApplyDependencies } from './reconciliation/application/automation/applyCoordinator';
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationRunHistoryPreserveCommand,
|
||||||
|
normalizeLocalReconciliationRunHistoryVerifyCommand,
|
||||||
|
type LocalReconciliationRunHistoryOptions,
|
||||||
|
type LocalReconciliationRunHistoryPreserveCommand,
|
||||||
|
type LocalReconciliationRunHistoryResult,
|
||||||
|
type LocalReconciliationRunHistoryVerifyCommand,
|
||||||
|
} from './reconciliation/application/run-history/contract';
|
||||||
|
export {
|
||||||
|
type LocalReconciliationRunHistoryDependencies,
|
||||||
|
type LocalReconciliationRunHistoryTerminal,
|
||||||
|
} from './reconciliation/application/run-history/coordinator';
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
type LocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
} from './reconciliation/application/run-history/evidence';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
normalizeLocalReconciliationApplicationCommitCommand,
|
normalizeLocalReconciliationApplicationCommitCommand,
|
||||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
applyLocalReconciliationAutomationCommandFile,
|
applyLocalReconciliationAutomationCommandFile,
|
||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
|
verifyLocalReconciliationRunHistoryCommandFile,
|
||||||
completeLocalReconciliationCommandFile,
|
completeLocalReconciliationCommandFile,
|
||||||
verifyLocalReconciliationCompletionCommandFile,
|
verifyLocalReconciliationCompletionCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
@@ -50,7 +52,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|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||||
|
|
||||||
async function main(argv: readonly string[]): Promise<void> {
|
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')) {
|
||||||
@@ -100,6 +102,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-automation-apply' &&
|
argv[0] !== 'reconciliation-automation-apply' &&
|
||||||
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||||
|
argv[0] !== 'reconciliation-run-history-preserve' &&
|
||||||
|
argv[0] !== 'reconciliation-run-history-verify' &&
|
||||||
argv[0] !== 'reconciliation-complete' &&
|
argv[0] !== 'reconciliation-complete' &&
|
||||||
argv[0] !== 'reconciliation-complete-verify' &&
|
argv[0] !== 'reconciliation-complete-verify' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
@@ -214,6 +218,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-automation-apply-rollback'
|
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||||
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-run-history-preserve'
|
||||||
|
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-run-history-verify'
|
||||||
|
? verifyLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-complete'
|
: argv[0] === 'reconciliation-complete'
|
||||||
? completeLocalReconciliationCommandFile(argv[2]!)
|
? completeLocalReconciliationCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-complete-verify'
|
: argv[0] === 'reconciliation-complete-verify'
|
||||||
|
|||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const SAFE_PATH = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly applicationRoot: string;
|
||||||
|
readonly runHistoryRoot: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryPreserveCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.run-history.preserve';
|
||||||
|
readonly options: Readonly<LocalReconciliationRunHistoryOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
preservationId: string;
|
||||||
|
applicationId: string;
|
||||||
|
expectedApplicationPlanDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
decisionFilePath: string;
|
||||||
|
preservedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryVerifyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.run-history.verify';
|
||||||
|
readonly options: Readonly<LocalReconciliationRunHistoryOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
preservationId: string;
|
||||||
|
applicationId: string;
|
||||||
|
expectedPreservationDigest: string;
|
||||||
|
decisionFilePath: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation:
|
||||||
|
| LocalReconciliationRunHistoryPreserveCommand['operation']
|
||||||
|
| LocalReconciliationRunHistoryVerifyCommand['operation'];
|
||||||
|
readonly status: 'preserved' | 'existing' | 'verified';
|
||||||
|
readonly state: 'reconciliation_run_history_preserved';
|
||||||
|
readonly preservationId: string;
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly preservationDigest: string;
|
||||||
|
readonly legacyFactCount: number;
|
||||||
|
readonly targetFactCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation run history ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
fail(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePath(value: unknown, label: string): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!path.isAbsolute(value) ||
|
||||||
|
path.parse(value).root === value ||
|
||||||
|
path.normalize(value) !== value ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
value.includes('//') ||
|
||||||
|
!SAFE_PATH.test(value) ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > 4_096
|
||||||
|
) {
|
||||||
|
fail(`${label} must be a safe non-root absolute path`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(left: string, right: string): boolean {
|
||||||
|
const relative = path.relative(left, right);
|
||||||
|
return (
|
||||||
|
relative === '' ||
|
||||||
|
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(value: unknown, pattern: RegExp, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !pattern.test(value))
|
||||||
|
fail(`${label} is invalid`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryOptions> {
|
||||||
|
const selected = record(value, 'options');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['allowRootService', 'applicationRoot', 'deploymentRoot', 'runHistoryRoot'],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
typeof selected.allowRootService !== 'boolean' ||
|
||||||
|
(currentIdentity().uid === 0) !== selected.allowRootService
|
||||||
|
) {
|
||||||
|
fail('command identity is invalid');
|
||||||
|
}
|
||||||
|
const roots = [
|
||||||
|
safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||||
|
safePath(selected.applicationRoot, 'applicationRoot'),
|
||||||
|
safePath(selected.runHistoryRoot, 'runHistoryRoot'),
|
||||||
|
];
|
||||||
|
for (let left = 0; left < roots.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < roots.length; right += 1) {
|
||||||
|
if (
|
||||||
|
overlaps(roots[left]!, roots[right]!) ||
|
||||||
|
overlaps(roots[right]!, roots[left]!)
|
||||||
|
) {
|
||||||
|
fail('authority roots overlap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: roots[0]!,
|
||||||
|
applicationRoot: roots[1]!,
|
||||||
|
runHistoryRoot: roots[2]!,
|
||||||
|
allowRootService: selected.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(value: unknown, operation: string) {
|
||||||
|
const selected = record(value, 'command');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
|
'command',
|
||||||
|
);
|
||||||
|
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||||
|
fail('command version or operation is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
options: normalizeOptions(selected.options),
|
||||||
|
request: record(selected.request, 'request'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionFilePath(
|
||||||
|
value: unknown,
|
||||||
|
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||||
|
): string {
|
||||||
|
const selected = safePath(value, 'decisionFilePath');
|
||||||
|
for (const root of [
|
||||||
|
options.deploymentRoot,
|
||||||
|
options.applicationRoot,
|
||||||
|
options.runHistoryRoot,
|
||||||
|
]) {
|
||||||
|
if (overlaps(root, selected) || overlaps(selected, root)) {
|
||||||
|
fail('decisionFilePath overlaps an authority root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationRunHistoryPreserveCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryPreserveCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.run-history.preserve',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'decisionFilePath',
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'preservationId',
|
||||||
|
'preservedAtMs',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.preservedAtMs) ||
|
||||||
|
(selected.request.preservedAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
fail('preservedAtMs is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.preserve',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
preservationId: identifier(
|
||||||
|
selected.request.preservationId,
|
||||||
|
UUID_V4,
|
||||||
|
'preservationId',
|
||||||
|
),
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
UUID_V4,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
expectedApplicationPlanDigest: identifier(
|
||||||
|
selected.request.expectedApplicationPlanDigest,
|
||||||
|
DIGEST,
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: identifier(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
DIGEST,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
decisionFilePath: decisionFilePath(
|
||||||
|
selected.request.decisionFilePath,
|
||||||
|
selected.options,
|
||||||
|
),
|
||||||
|
preservedAtMs: selected.request.preservedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationRunHistoryVerifyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryVerifyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.run-history.verify',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'decisionFilePath',
|
||||||
|
'expectedPreservationDigest',
|
||||||
|
'preservationId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.verify',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
preservationId: identifier(
|
||||||
|
selected.request.preservationId,
|
||||||
|
UUID_V4,
|
||||||
|
'preservationId',
|
||||||
|
),
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
UUID_V4,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
expectedPreservationDigest: identifier(
|
||||||
|
selected.request.expectedPreservationDigest,
|
||||||
|
DIGEST,
|
||||||
|
'expectedPreservationDigest',
|
||||||
|
),
|
||||||
|
decisionFilePath: decisionFilePath(
|
||||||
|
selected.request.decisionFilePath,
|
||||||
|
selected.options,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+623
@@ -0,0 +1,623 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import {
|
||||||
|
preflightPublishedFile,
|
||||||
|
publishExactFile,
|
||||||
|
validatePrivateDirectory,
|
||||||
|
} from '../../../foundation/files';
|
||||||
|
import { readLocalCutoverInstanceHead } from '../../../cutover/instanceLineage';
|
||||||
|
import { readLocalReconciliationPlanTerminal } from '../../planning/preparation';
|
||||||
|
import {
|
||||||
|
assertLocalReconciliationReviewDecisionMatchesFact,
|
||||||
|
withLocalReconciliationReviewDecisionFile,
|
||||||
|
} from '../../review/decisionFile';
|
||||||
|
import { visitLocalReconciliationDiagnosticFacts } from '../../review/diagnostics';
|
||||||
|
import { readLocalReconciliationReviewTerminalJson } from '../../review/terminalEvidence';
|
||||||
|
import {
|
||||||
|
withLocalReconciliationSealedDatabase,
|
||||||
|
type LocalReconciliationSealedBundleReaderDependencies,
|
||||||
|
} from '../../sealed-bundle/reader';
|
||||||
|
import {
|
||||||
|
readLocalReconciliationApplicationTerminal,
|
||||||
|
type LocalReconciliationApplicationTerminal,
|
||||||
|
} from '../coordinator';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationRunHistoryPreserveCommand,
|
||||||
|
normalizeLocalReconciliationRunHistoryVerifyCommand,
|
||||||
|
type LocalReconciliationRunHistoryOptions,
|
||||||
|
type LocalReconciliationRunHistoryPreserveCommand,
|
||||||
|
type LocalReconciliationRunHistoryResult,
|
||||||
|
type LocalReconciliationRunHistoryVerifyCommand,
|
||||||
|
} from './contract';
|
||||||
|
import {
|
||||||
|
buildLocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
localReconciliationRunHistoryReceiptContents,
|
||||||
|
normalizeLocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
type LocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
} from './evidence';
|
||||||
|
|
||||||
|
const MAX_PRESERVATIONS = 64;
|
||||||
|
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
interface RunHistoryPaths {
|
||||||
|
readonly root: string;
|
||||||
|
readonly receipt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunHistoryAuthority {
|
||||||
|
readonly application: Readonly<LocalReconciliationApplicationTerminal>;
|
||||||
|
readonly runHistoryInventoryDigest: string;
|
||||||
|
readonly bundleDigest: string;
|
||||||
|
readonly bundleFingerprintDigest: string;
|
||||||
|
readonly decisionFileDigest: string;
|
||||||
|
readonly legacyFactCount: number;
|
||||||
|
readonly targetFactCount: number;
|
||||||
|
confirmDecisionFileIdentity(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryDependencies
|
||||||
|
extends LocalReconciliationSealedBundleReaderDependencies {
|
||||||
|
readonly afterReceiptPublished?: () => void;
|
||||||
|
readonly afterTerminalSealed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryTerminal {
|
||||||
|
readonly receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||||
|
readonly application: Readonly<LocalReconciliationApplicationTerminal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation run history ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHistoryPaths(
|
||||||
|
runHistoryRoot: string,
|
||||||
|
preservationId: string,
|
||||||
|
): Readonly<RunHistoryPaths> {
|
||||||
|
const root = path.join(runHistoryRoot, preservationId);
|
||||||
|
return Object.freeze({ root, receipt: path.join(root, 'receipt.json') });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDirectory(
|
||||||
|
directory: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
label: string,
|
||||||
|
): number {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(directory);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(`${label} is unavailable`, error);
|
||||||
|
}
|
||||||
|
const mode = stat.mode & 0o777;
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
!modes.includes(mode) ||
|
||||||
|
fs.realpathSync(directory) !== directory
|
||||||
|
) {
|
||||||
|
fail(`${label} identity is invalid`);
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCatalog(selected: Readonly<RunHistoryPaths>, sealed: boolean) {
|
||||||
|
const allowed = new Set([
|
||||||
|
'receipt.json',
|
||||||
|
...(!sealed ? ['.receipt.json.ql3-deploy-stage'] : []),
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||||
|
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
fail('preservation catalog contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePreservationDirectory(
|
||||||
|
runHistoryRoot: string,
|
||||||
|
preservationId: string,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<RunHistoryPaths> {
|
||||||
|
const selected = runHistoryPaths(runHistoryRoot, preservationId);
|
||||||
|
const entries = fs.readdirSync(runHistoryRoot, { withFileTypes: true });
|
||||||
|
if (entries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink())) {
|
||||||
|
fail('preservation root catalog contains drift');
|
||||||
|
}
|
||||||
|
if (entries.length >= MAX_PRESERVATIONS && !fs.existsSync(selected.root)) {
|
||||||
|
fail('preservation retention limit is reached');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(selected.root, { mode: 0o700 });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
fail('preservation directory cannot be created', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mode = validateDirectory(
|
||||||
|
selected.root,
|
||||||
|
uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'preservation directory',
|
||||||
|
);
|
||||||
|
validateCatalog(selected, mode === 0o500);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReceipt(
|
||||||
|
selected: Readonly<RunHistoryPaths>,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(selected.receipt);
|
||||||
|
} catch (error) {
|
||||||
|
return fail('preservation receipt is unavailable', error);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
!modes.includes(stat.mode & 0o777) ||
|
||||||
|
stat.nlink !== 1 ||
|
||||||
|
stat.size < 2 ||
|
||||||
|
stat.size > MAX_RECEIPT_BYTES
|
||||||
|
) {
|
||||||
|
fail('preservation receipt identity is invalid');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeLocalReconciliationRunHistoryPreservationReceipt(
|
||||||
|
readLocalReconciliationReviewTerminalJson(selected.receipt, uid, modes),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return fail('preservation receipt cannot be read', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealPreservation(
|
||||||
|
selected: Readonly<RunHistoryPaths>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
validateDirectory(
|
||||||
|
selected.root,
|
||||||
|
uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'preservation directory',
|
||||||
|
);
|
||||||
|
readReceipt(selected, uid, [0o600, 0o400]);
|
||||||
|
let descriptor = fs.openSync(
|
||||||
|
selected.receipt,
|
||||||
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const stat = fs.fstatSync(descriptor);
|
||||||
|
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
|
||||||
|
fail('preservation receipt cannot be sealed');
|
||||||
|
}
|
||||||
|
if ((stat.mode & 0o777) !== 0o400) {
|
||||||
|
fs.fchmodSync(descriptor, 0o400);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
descriptor = fs.openSync(selected.root, fs.constants.O_RDONLY);
|
||||||
|
try {
|
||||||
|
const stat = fs.fstatSync(descriptor);
|
||||||
|
if ((stat.mode & 0o777) !== 0o500) fs.fchmodSync(descriptor, 0o500);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
readReceipt(selected, uid, [0o400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApplication(
|
||||||
|
application: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||||
|
applicationId: string,
|
||||||
|
applicationPlanDigest: string,
|
||||||
|
): void {
|
||||||
|
const runHistory = application.plan.domains.find(
|
||||||
|
(domain) => domain.domain === 'run_history',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
application.intent.command.options.deploymentRoot !==
|
||||||
|
options.deploymentRoot ||
|
||||||
|
application.intent.command.options.applicationRoot !==
|
||||||
|
options.applicationRoot ||
|
||||||
|
application.plan.applicationId !== applicationId ||
|
||||||
|
application.plan.applicationPlanDigest !== applicationPlanDigest ||
|
||||||
|
!runHistory ||
|
||||||
|
runHistory.action !== 'adapter_required'
|
||||||
|
) {
|
||||||
|
fail('preservation authority is detached from the application plan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSourceHead(
|
||||||
|
application: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
expectedHeadDigest: string,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
application.intent.command.options.deploymentRoot,
|
||||||
|
application.intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const automation = application.plan.domains.find(
|
||||||
|
(domain) => domain.domain === 'automation',
|
||||||
|
);
|
||||||
|
const expectedState =
|
||||||
|
automation?.action === 'no_effect'
|
||||||
|
? 'reconciliation_application_planned'
|
||||||
|
: automation?.action === 'adapter_required'
|
||||||
|
? 'reconciliation_automation_applied'
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
expectedState === null ||
|
||||||
|
head.headDigest !== expectedHeadDigest ||
|
||||||
|
head.state !== expectedState ||
|
||||||
|
head.instanceId !== application.intent.instanceId ||
|
||||||
|
head.cutoverId !== application.intent.cutoverId ||
|
||||||
|
head.generation !== application.intent.generation ||
|
||||||
|
head.activationDigest !== application.intent.activationDigest
|
||||||
|
) {
|
||||||
|
fail('preservation lost source head compare-and-swap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveAuthority(
|
||||||
|
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||||
|
applicationId: string,
|
||||||
|
applicationPlanDigest: string,
|
||||||
|
decisionFilePath: string,
|
||||||
|
uid: number,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies,
|
||||||
|
): Promise<Readonly<RunHistoryAuthority>> {
|
||||||
|
const application = await readLocalReconciliationApplicationTerminal(
|
||||||
|
options.applicationRoot,
|
||||||
|
applicationId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
validateApplication(
|
||||||
|
application,
|
||||||
|
options,
|
||||||
|
applicationId,
|
||||||
|
applicationPlanDigest,
|
||||||
|
);
|
||||||
|
const planTerminal = readLocalReconciliationPlanTerminal(
|
||||||
|
application.review.intent.command.options.planRoot,
|
||||||
|
application.review.intent.command.request.planId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const runHistory = planTerminal.plan.domains.find(
|
||||||
|
(domain) => domain.domain === 'run_history',
|
||||||
|
);
|
||||||
|
if (!runHistory) fail('run history inventory is absent');
|
||||||
|
let legacyFactCount = 0;
|
||||||
|
let targetFactCount = 0;
|
||||||
|
const reviewed = withLocalReconciliationReviewDecisionFile(
|
||||||
|
decisionFilePath,
|
||||||
|
{
|
||||||
|
reviewId: application.review.review.reviewId,
|
||||||
|
profile: application.plan.profile,
|
||||||
|
planDigest: planTerminal.plan.planDigest,
|
||||||
|
preparationDigest: application.review.intent.preparationDigest,
|
||||||
|
},
|
||||||
|
(cursor) => {
|
||||||
|
for (const database of ['legacy', 'target'] as const) {
|
||||||
|
const opened = withLocalReconciliationSealedDatabase(
|
||||||
|
planTerminal.bundle,
|
||||||
|
database,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
(client) =>
|
||||||
|
visitLocalReconciliationDiagnosticFacts(
|
||||||
|
client,
|
||||||
|
database,
|
||||||
|
(fact) => {
|
||||||
|
if (fact.decisionRequirement === 'informational') return;
|
||||||
|
const decision = cursor.next();
|
||||||
|
if (decision === null) {
|
||||||
|
fail('decision file omitted a canonical fact');
|
||||||
|
}
|
||||||
|
assertLocalReconciliationReviewDecisionMatchesFact(
|
||||||
|
decision,
|
||||||
|
fact,
|
||||||
|
);
|
||||||
|
if (fact.domain !== 'run_history') return;
|
||||||
|
if (
|
||||||
|
fact.decisionRequirement !== 'required' ||
|
||||||
|
fact.reason !== 'historical_preservation_required' ||
|
||||||
|
(database === 'legacy' &&
|
||||||
|
decision.disposition !== 'retain_both') ||
|
||||||
|
(database === 'target' &&
|
||||||
|
decision.disposition !== 'retain_target')
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'signed review did not authorize append-only preservation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (database === 'legacy') legacyFactCount += 1;
|
||||||
|
else targetFactCount += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (opened === null) {
|
||||||
|
fail('manual-required SQLite topology cannot be preserved');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
reviewed.evidence.fileDigest !==
|
||||||
|
application.review.authorization.decisionFileDigest ||
|
||||||
|
reviewed.evidence.decisionCount !==
|
||||||
|
application.review.authorization.decisionCount ||
|
||||||
|
legacyFactCount < 1 ||
|
||||||
|
targetFactCount < 1
|
||||||
|
) {
|
||||||
|
fail('signed review authority drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
application,
|
||||||
|
runHistoryInventoryDigest: runHistory.inventoryDigest,
|
||||||
|
bundleDigest: planTerminal.bundle.receipt.bundleDigest,
|
||||||
|
bundleFingerprintDigest: planTerminal.bundle.fingerprintDigest,
|
||||||
|
decisionFileDigest: reviewed.evidence.fileDigest,
|
||||||
|
legacyFactCount,
|
||||||
|
targetFactCount,
|
||||||
|
confirmDecisionFileIdentity: reviewed.confirmIdentity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReceiptBinding(
|
||||||
|
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||||
|
authority: Readonly<RunHistoryAuthority>,
|
||||||
|
preservationId: string,
|
||||||
|
): void {
|
||||||
|
const application = authority.application;
|
||||||
|
if (
|
||||||
|
receipt.preservationId !== preservationId ||
|
||||||
|
receipt.applicationId !== application.plan.applicationId ||
|
||||||
|
receipt.profile !== application.plan.profile ||
|
||||||
|
receipt.instanceId !== application.intent.instanceId ||
|
||||||
|
receipt.cutoverId !== application.intent.cutoverId ||
|
||||||
|
receipt.generation !== application.intent.generation ||
|
||||||
|
receipt.activationDigest !== application.intent.activationDigest ||
|
||||||
|
receipt.applicationPlanDigest !== application.plan.applicationPlanDigest ||
|
||||||
|
receipt.reviewDigest !== application.review.review.reviewDigest ||
|
||||||
|
receipt.reviewAuthorizationDigest !==
|
||||||
|
application.review.authorization.authorizationDigest ||
|
||||||
|
receipt.reviewDecisionSetDigest !==
|
||||||
|
application.review.authorization.decisionSetDigest ||
|
||||||
|
receipt.reviewDecisionFileDigest !== authority.decisionFileDigest ||
|
||||||
|
receipt.bundleDigest !== authority.bundleDigest ||
|
||||||
|
receipt.bundleFingerprintDigest !== authority.bundleFingerprintDigest ||
|
||||||
|
receipt.runHistoryInventoryDigest !== authority.runHistoryInventoryDigest ||
|
||||||
|
receipt.legacyFactCount !== authority.legacyFactCount ||
|
||||||
|
receipt.targetFactCount !== authority.targetFactCount
|
||||||
|
) {
|
||||||
|
fail('preservation receipt is detached from terminal authority');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(
|
||||||
|
operation: LocalReconciliationRunHistoryResult['operation'],
|
||||||
|
status: LocalReconciliationRunHistoryResult['status'],
|
||||||
|
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryResult> {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
state: 'reconciliation_run_history_preserved',
|
||||||
|
preservationId: receipt.preservationId,
|
||||||
|
applicationId: receipt.applicationId,
|
||||||
|
preservationDigest: receipt.preservationDigest,
|
||||||
|
legacyFactCount: receipt.legacyFactCount,
|
||||||
|
targetFactCount: receipt.targetFactCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalReconciliationRunHistoryTerminal(
|
||||||
|
options: Readonly<LocalReconciliationRunHistoryOptions>,
|
||||||
|
preservationId: string,
|
||||||
|
applicationId: string,
|
||||||
|
decisionFilePath: string,
|
||||||
|
uid: number,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationRunHistoryTerminal>> {
|
||||||
|
const selected = runHistoryPaths(options.runHistoryRoot, preservationId);
|
||||||
|
validateDirectory(selected.root, uid, [0o500], 'preservation directory');
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
const receipt = readReceipt(selected, uid, [0o400]);
|
||||||
|
if (receipt.applicationId !== applicationId) {
|
||||||
|
fail('preservation path binding drifted');
|
||||||
|
}
|
||||||
|
const authority = await deriveAuthority(
|
||||||
|
options,
|
||||||
|
applicationId,
|
||||||
|
receipt.applicationPlanDigest,
|
||||||
|
decisionFilePath,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
validateReceiptBinding(receipt, authority, preservationId);
|
||||||
|
authority.confirmDecisionFileIdentity();
|
||||||
|
return Object.freeze({ receipt, application: authority.application });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preserveLocalReconciliationRunHistory(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationRunHistoryResult>> {
|
||||||
|
const command = normalizeLocalReconciliationRunHistoryPreserveCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.runHistoryRoot, 'runHistoryRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, uid, label);
|
||||||
|
}
|
||||||
|
const selected = ensurePreservationDirectory(
|
||||||
|
command.options.runHistoryRoot,
|
||||||
|
command.request.preservationId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const authority = await deriveAuthority(
|
||||||
|
command.options,
|
||||||
|
command.request.applicationId,
|
||||||
|
command.request.expectedApplicationPlanDigest,
|
||||||
|
command.request.decisionFilePath,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
let receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||||
|
let status: 'preserved' | 'existing' = 'preserved';
|
||||||
|
if (fs.existsSync(selected.receipt)) {
|
||||||
|
status = 'existing';
|
||||||
|
receipt = readReceipt(selected, uid, [0o600, 0o400]);
|
||||||
|
validateReceiptBinding(receipt, authority, command.request.preservationId);
|
||||||
|
if (
|
||||||
|
receipt.sourceHeadDigest !== command.request.expectedHeadDigest ||
|
||||||
|
receipt.preservedAtMs !== command.request.preservedAtMs
|
||||||
|
) {
|
||||||
|
fail('preservation command is not an exact replay');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assertSourceHead(
|
||||||
|
authority.application,
|
||||||
|
command.request.expectedHeadDigest,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
command.request.preservedAtMs < authority.application.plan.committedAtMs
|
||||||
|
) {
|
||||||
|
fail('preservation timestamp precedes application evidence');
|
||||||
|
}
|
||||||
|
receipt = buildLocalReconciliationRunHistoryPreservationReceipt({
|
||||||
|
preservationId: command.request.preservationId,
|
||||||
|
applicationId: command.request.applicationId,
|
||||||
|
profile: authority.application.plan.profile,
|
||||||
|
instanceId: authority.application.intent.instanceId,
|
||||||
|
cutoverId: authority.application.intent.cutoverId,
|
||||||
|
generation: authority.application.intent.generation,
|
||||||
|
activationDigest: authority.application.intent.activationDigest,
|
||||||
|
applicationPlanDigest: authority.application.plan.applicationPlanDigest,
|
||||||
|
sourceHeadDigest: command.request.expectedHeadDigest,
|
||||||
|
reviewDigest: authority.application.review.review.reviewDigest,
|
||||||
|
reviewAuthorizationDigest:
|
||||||
|
authority.application.review.authorization.authorizationDigest,
|
||||||
|
reviewDecisionSetDigest:
|
||||||
|
authority.application.review.authorization.decisionSetDigest,
|
||||||
|
reviewDecisionFileDigest: authority.decisionFileDigest,
|
||||||
|
bundleDigest: authority.bundleDigest,
|
||||||
|
bundleFingerprintDigest: authority.bundleFingerprintDigest,
|
||||||
|
runHistoryInventoryDigest: authority.runHistoryInventoryDigest,
|
||||||
|
legacyFactCount: authority.legacyFactCount,
|
||||||
|
targetFactCount: authority.targetFactCount,
|
||||||
|
preservedAtMs: command.request.preservedAtMs,
|
||||||
|
});
|
||||||
|
const contents = localReconciliationRunHistoryReceiptContents(receipt);
|
||||||
|
preflightPublishedFile(
|
||||||
|
selected.receipt,
|
||||||
|
contents,
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'run history preservation receipt',
|
||||||
|
);
|
||||||
|
authority.confirmDecisionFileIdentity();
|
||||||
|
assertSourceHead(
|
||||||
|
authority.application,
|
||||||
|
command.request.expectedHeadDigest,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
publishExactFile(
|
||||||
|
selected.receipt,
|
||||||
|
contents,
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'run history preservation receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterReceiptPublished?.();
|
||||||
|
}
|
||||||
|
sealPreservation(selected, uid);
|
||||||
|
dependencies.afterTerminalSealed?.();
|
||||||
|
authority.confirmDecisionFileIdentity();
|
||||||
|
assertSourceHead(
|
||||||
|
authority.application,
|
||||||
|
command.request.expectedHeadDigest,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
return result(command.operation, status, receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationRunHistory(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationRunHistoryResult>> {
|
||||||
|
const command = normalizeLocalReconciliationRunHistoryVerifyCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.runHistoryRoot, 'runHistoryRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, uid, label);
|
||||||
|
}
|
||||||
|
const terminal = await readLocalReconciliationRunHistoryTerminal(
|
||||||
|
command.options,
|
||||||
|
command.request.preservationId,
|
||||||
|
command.request.applicationId,
|
||||||
|
command.request.decisionFilePath,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
terminal.receipt.preservationDigest !==
|
||||||
|
command.request.expectedPreservationDigest
|
||||||
|
) {
|
||||||
|
fail('verify command is detached from preservation receipt');
|
||||||
|
}
|
||||||
|
return result(command.operation, 'verified', terminal.receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preserveLocalReconciliationRunHistoryCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||||
|
) {
|
||||||
|
return preserveLocalReconciliationRunHistory(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLocalReconciliationRunHistoryCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
dependencies: LocalReconciliationRunHistoryDependencies = {},
|
||||||
|
) {
|
||||||
|
return verifyLocalReconciliationRunHistory(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LocalReconciliationRunHistoryPreserveCommand,
|
||||||
|
LocalReconciliationRunHistoryVerifyCommand,
|
||||||
|
};
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||||
|
|
||||||
|
const RECEIPT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-run-history-preservation-receipt';
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationRunHistoryPreservationReceipt {
|
||||||
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_run_history_preserved';
|
||||||
|
readonly preservationId: string;
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly cutoverId: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly activationDigest: string;
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
readonly sourceHeadDigest: string;
|
||||||
|
readonly reviewDigest: string;
|
||||||
|
readonly reviewAuthorizationDigest: string;
|
||||||
|
readonly reviewDecisionSetDigest: string;
|
||||||
|
readonly reviewDecisionFileDigest: string;
|
||||||
|
readonly bundleDigest: string;
|
||||||
|
readonly bundleFingerprintDigest: string;
|
||||||
|
readonly runHistoryInventoryDigest: string;
|
||||||
|
readonly legacyFactCount: number;
|
||||||
|
readonly targetFactCount: number;
|
||||||
|
readonly preservedAtMs: number;
|
||||||
|
readonly preservationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation run history evidence ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail('receipt must be an object');
|
||||||
|
}
|
||||||
|
const selected = value as Record<string, unknown>;
|
||||||
|
const actual = Object.keys(selected).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
fail('receipt shape is invalid');
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationRunHistoryPreservationReceipt(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationRunHistoryPreservationReceipt,
|
||||||
|
'schema' | 'schemaVersion' | 'state' | 'preservationDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: RECEIPT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_run_history_preserved' as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
preservationDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationRunHistoryPreservationReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationRunHistoryPreservationReceipt> {
|
||||||
|
const selected = exact(value, [
|
||||||
|
'activationDigest',
|
||||||
|
'applicationId',
|
||||||
|
'applicationPlanDigest',
|
||||||
|
'bundleDigest',
|
||||||
|
'bundleFingerprintDigest',
|
||||||
|
'cutoverId',
|
||||||
|
'generation',
|
||||||
|
'instanceId',
|
||||||
|
'legacyFactCount',
|
||||||
|
'preservationDigest',
|
||||||
|
'preservationId',
|
||||||
|
'preservedAtMs',
|
||||||
|
'profile',
|
||||||
|
'reviewAuthorizationDigest',
|
||||||
|
'reviewDecisionFileDigest',
|
||||||
|
'reviewDecisionSetDigest',
|
||||||
|
'reviewDigest',
|
||||||
|
'runHistoryInventoryDigest',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'sourceHeadDigest',
|
||||||
|
'state',
|
||||||
|
'targetFactCount',
|
||||||
|
]);
|
||||||
|
const { preservationDigest, ...payload } = selected;
|
||||||
|
if (
|
||||||
|
selected.schema !== RECEIPT_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
selected.state !== 'reconciliation_run_history_preserved' ||
|
||||||
|
typeof selected.preservationId !== 'string' ||
|
||||||
|
!UUID_V4.test(selected.preservationId) ||
|
||||||
|
typeof selected.applicationId !== 'string' ||
|
||||||
|
!UUID_V4.test(selected.applicationId) ||
|
||||||
|
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
|
||||||
|
typeof selected.instanceId !== 'string' ||
|
||||||
|
selected.instanceId.length < 1 ||
|
||||||
|
typeof selected.cutoverId !== 'string' ||
|
||||||
|
selected.cutoverId.length < 1 ||
|
||||||
|
!Number.isSafeInteger(selected.generation) ||
|
||||||
|
(selected.generation as number) < 1 ||
|
||||||
|
![
|
||||||
|
selected.activationDigest,
|
||||||
|
selected.applicationPlanDigest,
|
||||||
|
selected.sourceHeadDigest,
|
||||||
|
selected.reviewDigest,
|
||||||
|
selected.reviewAuthorizationDigest,
|
||||||
|
selected.reviewDecisionSetDigest,
|
||||||
|
selected.reviewDecisionFileDigest,
|
||||||
|
selected.bundleDigest,
|
||||||
|
selected.bundleFingerprintDigest,
|
||||||
|
selected.runHistoryInventoryDigest,
|
||||||
|
preservationDigest,
|
||||||
|
].every(
|
||||||
|
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||||
|
) ||
|
||||||
|
!Number.isSafeInteger(selected.legacyFactCount) ||
|
||||||
|
(selected.legacyFactCount as number) < 1 ||
|
||||||
|
!Number.isSafeInteger(selected.targetFactCount) ||
|
||||||
|
(selected.targetFactCount as number) < 1 ||
|
||||||
|
!Number.isSafeInteger(selected.preservedAtMs) ||
|
||||||
|
(selected.preservedAtMs as number) < 0 ||
|
||||||
|
cutoverDigest(payload) !== preservationDigest
|
||||||
|
) {
|
||||||
|
fail('receipt binding is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
preservationDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localReconciliationRunHistoryReceiptContents(
|
||||||
|
receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>,
|
||||||
|
): string {
|
||||||
|
return `${JSON.stringify(receipt, null, 2)}\n`;
|
||||||
|
}
|
||||||
+134
-23
@@ -22,17 +22,28 @@ export interface LocalReconciliationCompletionOptions {
|
|||||||
readonly applicationRoot: string;
|
readonly applicationRoot: string;
|
||||||
readonly completionRoot: string;
|
readonly completionRoot: string;
|
||||||
readonly automation: Readonly<LocalReconciliationCompletionAutomationOptions> | null;
|
readonly automation: Readonly<LocalReconciliationCompletionAutomationOptions> | null;
|
||||||
|
readonly runHistory: Readonly<LocalReconciliationCompletionRunHistoryOptions> | null;
|
||||||
readonly allowRootService: boolean;
|
readonly allowRootService: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationCompletionRunHistoryOptions {
|
||||||
|
readonly runHistoryRoot: string;
|
||||||
|
readonly decisionFilePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationCompletionAutomationBinding {
|
export interface LocalReconciliationCompletionAutomationBinding {
|
||||||
readonly automationId: string;
|
readonly automationId: string;
|
||||||
readonly decisionId: string;
|
readonly decisionId: string;
|
||||||
readonly expectedApplyDigest: string;
|
readonly expectedApplyDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationCompletionRunHistoryBinding {
|
||||||
|
readonly preservationId: string;
|
||||||
|
readonly expectedPreservationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationCompleteCommand {
|
export interface LocalReconciliationCompleteCommand {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1 | 2;
|
||||||
readonly operation: 'local.deployment.reconciliation.complete';
|
readonly operation: 'local.deployment.reconciliation.complete';
|
||||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||||
readonly request: Readonly<{
|
readonly request: Readonly<{
|
||||||
@@ -41,12 +52,13 @@ export interface LocalReconciliationCompleteCommand {
|
|||||||
expectedApplicationPlanDigest: string;
|
expectedApplicationPlanDigest: string;
|
||||||
expectedHeadDigest: string;
|
expectedHeadDigest: string;
|
||||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||||
|
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||||
completedAtMs: number;
|
completedAtMs: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationCompletionVerifyCommand {
|
export interface LocalReconciliationCompletionVerifyCommand {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1 | 2;
|
||||||
readonly operation: 'local.deployment.reconciliation.complete.verify';
|
readonly operation: 'local.deployment.reconciliation.complete.verify';
|
||||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||||
readonly request: Readonly<{
|
readonly request: Readonly<{
|
||||||
@@ -54,6 +66,7 @@ export interface LocalReconciliationCompletionVerifyCommand {
|
|||||||
applicationId: string;
|
applicationId: string;
|
||||||
expectedCompletionDigest: string;
|
expectedCompletionDigest: string;
|
||||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||||
|
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +81,7 @@ export interface LocalReconciliationCompletionResult {
|
|||||||
readonly applicationId: string;
|
readonly applicationId: string;
|
||||||
readonly completionDigest: string;
|
readonly completionDigest: string;
|
||||||
readonly domainCount: 8;
|
readonly domainCount: 8;
|
||||||
readonly adapterCount: 0 | 1;
|
readonly adapterCount: 0 | 1 | 2;
|
||||||
readonly instanceHeadDigest: string;
|
readonly instanceHeadDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,17 +182,27 @@ function normalizeAutomationOptions(
|
|||||||
|
|
||||||
function normalizeOptions(
|
function normalizeOptions(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
schemaVersion: 1 | 2,
|
||||||
): Readonly<LocalReconciliationCompletionOptions> {
|
): Readonly<LocalReconciliationCompletionOptions> {
|
||||||
const selected = record(value, 'options');
|
const selected = record(value, 'options');
|
||||||
exact(
|
exact(
|
||||||
selected,
|
selected,
|
||||||
[
|
schemaVersion === 1
|
||||||
'allowRootService',
|
? [
|
||||||
'applicationRoot',
|
'allowRootService',
|
||||||
'automation',
|
'applicationRoot',
|
||||||
'completionRoot',
|
'automation',
|
||||||
'deploymentRoot',
|
'completionRoot',
|
||||||
],
|
'deploymentRoot',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'allowRootService',
|
||||||
|
'applicationRoot',
|
||||||
|
'automation',
|
||||||
|
'completionRoot',
|
||||||
|
'deploymentRoot',
|
||||||
|
'runHistory',
|
||||||
|
],
|
||||||
'options',
|
'options',
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -189,11 +212,16 @@ function normalizeOptions(
|
|||||||
fail('command identity is invalid');
|
fail('command identity is invalid');
|
||||||
}
|
}
|
||||||
const automation = normalizeAutomationOptions(selected.automation);
|
const automation = normalizeAutomationOptions(selected.automation);
|
||||||
|
const runHistory =
|
||||||
|
schemaVersion === 1
|
||||||
|
? null
|
||||||
|
: normalizeRunHistoryOptions(selected.runHistory);
|
||||||
const normalized = Object.freeze({
|
const normalized = Object.freeze({
|
||||||
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||||
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||||
completionRoot: safePath(selected.completionRoot, 'completionRoot'),
|
completionRoot: safePath(selected.completionRoot, 'completionRoot'),
|
||||||
automation,
|
automation,
|
||||||
|
runHistory,
|
||||||
allowRootService: selected.allowRootService,
|
allowRootService: selected.allowRootService,
|
||||||
}) as Readonly<LocalReconciliationCompletionOptions>;
|
}) as Readonly<LocalReconciliationCompletionOptions>;
|
||||||
const roots = [
|
const roots = [
|
||||||
@@ -207,6 +235,7 @@ function normalizeOptions(
|
|||||||
automation.automationDecisionRoot,
|
automation.automationDecisionRoot,
|
||||||
automation.automationApplyRoot,
|
automation.automationApplyRoot,
|
||||||
]),
|
]),
|
||||||
|
...(runHistory === null ? [] : [runHistory.runHistoryRoot]),
|
||||||
];
|
];
|
||||||
for (let left = 0; left < roots.length; left += 1) {
|
for (let left = 0; left < roots.length; left += 1) {
|
||||||
for (let right = left + 1; right < roots.length; right += 1) {
|
for (let right = left + 1; right < roots.length; right += 1) {
|
||||||
@@ -228,9 +257,34 @@ function normalizeOptions(
|
|||||||
) {
|
) {
|
||||||
fail('targetDatabasePath overlaps an authority root');
|
fail('targetDatabasePath overlaps an authority root');
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
runHistory !== null &&
|
||||||
|
roots.some(
|
||||||
|
(root) =>
|
||||||
|
overlaps(root, runHistory.decisionFilePath) ||
|
||||||
|
overlaps(runHistory.decisionFilePath, root),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('decisionFilePath overlaps an authority root');
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRunHistoryOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationCompletionRunHistoryOptions> {
|
||||||
|
const selected = record(value, 'run history options');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['decisionFilePath', 'runHistoryRoot'],
|
||||||
|
'run history options',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
runHistoryRoot: safePath(selected.runHistoryRoot, 'runHistoryRoot'),
|
||||||
|
decisionFilePath: safePath(selected.decisionFilePath, 'decisionFilePath'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAutomationBinding(
|
function normalizeAutomationBinding(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Readonly<LocalReconciliationCompletionAutomationBinding> | null {
|
): Readonly<LocalReconciliationCompletionAutomationBinding> | null {
|
||||||
@@ -251,6 +305,28 @@ function normalizeAutomationBinding(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRunHistoryBinding(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationCompletionRunHistoryBinding> {
|
||||||
|
const selected = record(value, 'run history binding');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['expectedPreservationDigest', 'preservationId'],
|
||||||
|
'run history binding',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
preservationId: identifier(
|
||||||
|
selected.preservationId,
|
||||||
|
UUID_V4,
|
||||||
|
'preservationId',
|
||||||
|
),
|
||||||
|
expectedPreservationDigest: digest(
|
||||||
|
selected.expectedPreservationDigest,
|
||||||
|
'expectedPreservationDigest',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function command(value: unknown, operation: string) {
|
function command(value: unknown, operation: string) {
|
||||||
const selected = record(value, 'command');
|
const selected = record(value, 'command');
|
||||||
exact(
|
exact(
|
||||||
@@ -258,11 +334,15 @@ function command(value: unknown, operation: string) {
|
|||||||
['operation', 'options', 'request', 'schemaVersion'],
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
'command',
|
'command',
|
||||||
);
|
);
|
||||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
if (
|
||||||
|
(selected.schemaVersion !== 1 && selected.schemaVersion !== 2) ||
|
||||||
|
selected.operation !== operation
|
||||||
|
) {
|
||||||
fail('command version or operation is invalid');
|
fail('command version or operation is invalid');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
options: normalizeOptions(selected.options),
|
schemaVersion: selected.schemaVersion,
|
||||||
|
options: normalizeOptions(selected.options, selected.schemaVersion),
|
||||||
request: record(selected.request, 'request'),
|
request: record(selected.request, 'request'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -273,14 +353,24 @@ export function normalizeLocalReconciliationCompleteCommand(
|
|||||||
const selected = command(value, 'local.deployment.reconciliation.complete');
|
const selected = command(value, 'local.deployment.reconciliation.complete');
|
||||||
exact(
|
exact(
|
||||||
selected.request,
|
selected.request,
|
||||||
[
|
selected.schemaVersion === 1
|
||||||
'applicationId',
|
? [
|
||||||
'automation',
|
'applicationId',
|
||||||
'completedAtMs',
|
'automation',
|
||||||
'completionId',
|
'completedAtMs',
|
||||||
'expectedApplicationPlanDigest',
|
'completionId',
|
||||||
'expectedHeadDigest',
|
'expectedApplicationPlanDigest',
|
||||||
],
|
'expectedHeadDigest',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'applicationId',
|
||||||
|
'automation',
|
||||||
|
'completedAtMs',
|
||||||
|
'completionId',
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'runHistory',
|
||||||
|
],
|
||||||
'request',
|
'request',
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -290,7 +380,7 @@ export function normalizeLocalReconciliationCompleteCommand(
|
|||||||
fail('completedAtMs is invalid');
|
fail('completedAtMs is invalid');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: selected.schemaVersion,
|
||||||
operation: 'local.deployment.reconciliation.complete',
|
operation: 'local.deployment.reconciliation.complete',
|
||||||
options: selected.options,
|
options: selected.options,
|
||||||
request: Object.freeze({
|
request: Object.freeze({
|
||||||
@@ -313,6 +403,10 @@ export function normalizeLocalReconciliationCompleteCommand(
|
|||||||
'expectedHeadDigest',
|
'expectedHeadDigest',
|
||||||
),
|
),
|
||||||
automation: normalizeAutomationBinding(selected.request.automation),
|
automation: normalizeAutomationBinding(selected.request.automation),
|
||||||
|
runHistory:
|
||||||
|
selected.schemaVersion === 1
|
||||||
|
? null
|
||||||
|
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||||
completedAtMs: selected.request.completedAtMs as number,
|
completedAtMs: selected.request.completedAtMs as number,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -327,11 +421,24 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
|||||||
);
|
);
|
||||||
exact(
|
exact(
|
||||||
selected.request,
|
selected.request,
|
||||||
['applicationId', 'automation', 'completionId', 'expectedCompletionDigest'],
|
selected.schemaVersion === 1
|
||||||
|
? [
|
||||||
|
'applicationId',
|
||||||
|
'automation',
|
||||||
|
'completionId',
|
||||||
|
'expectedCompletionDigest',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'applicationId',
|
||||||
|
'automation',
|
||||||
|
'completionId',
|
||||||
|
'expectedCompletionDigest',
|
||||||
|
'runHistory',
|
||||||
|
],
|
||||||
'request',
|
'request',
|
||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: selected.schemaVersion,
|
||||||
operation: 'local.deployment.reconciliation.complete.verify',
|
operation: 'local.deployment.reconciliation.complete.verify',
|
||||||
options: selected.options,
|
options: selected.options,
|
||||||
request: Object.freeze({
|
request: Object.freeze({
|
||||||
@@ -350,6 +457,10 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
|||||||
'expectedCompletionDigest',
|
'expectedCompletionDigest',
|
||||||
),
|
),
|
||||||
automation: normalizeAutomationBinding(selected.request.automation),
|
automation: normalizeAutomationBinding(selected.request.automation),
|
||||||
|
runHistory:
|
||||||
|
selected.schemaVersion === 1
|
||||||
|
? null
|
||||||
|
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-5
@@ -33,6 +33,11 @@ import type {
|
|||||||
LocalReconciliationAutomationApplyReceipt,
|
LocalReconciliationAutomationApplyReceipt,
|
||||||
} from '../application/automation/applyEvidence';
|
} from '../application/automation/applyEvidence';
|
||||||
import { readLocalReconciliationAutomationDecisionTerminal } from '../application/automation/decisionCoordinator';
|
import { readLocalReconciliationAutomationDecisionTerminal } from '../application/automation/decisionCoordinator';
|
||||||
|
import {
|
||||||
|
readLocalReconciliationRunHistoryTerminal,
|
||||||
|
type LocalReconciliationRunHistoryDependencies,
|
||||||
|
} from '../application/run-history/coordinator';
|
||||||
|
import type { LocalReconciliationRunHistoryPreservationReceipt } from '../application/run-history/evidence';
|
||||||
import type { LocalReconciliationCompletionDomainEvidence } from './evidence';
|
import type { LocalReconciliationCompletionDomainEvidence } from './evidence';
|
||||||
import {
|
import {
|
||||||
buildLocalReconciliationCompletionReceipt,
|
buildLocalReconciliationCompletionReceipt,
|
||||||
@@ -63,7 +68,12 @@ interface AutomationProof {
|
|||||||
readonly paths: ReturnType<typeof localReconciliationAutomationApplyPaths>;
|
readonly paths: ReturnType<typeof localReconciliationAutomationApplyPaths>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationCompletionDependencies {
|
interface RunHistoryProof {
|
||||||
|
readonly receipt: Readonly<LocalReconciliationRunHistoryPreservationReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationCompletionDependencies
|
||||||
|
extends LocalReconciliationRunHistoryDependencies {
|
||||||
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
||||||
readonly afterReceiptPublished?: () => void;
|
readonly afterReceiptPublished?: () => void;
|
||||||
readonly afterTerminalSealed?: () => void;
|
readonly afterTerminalSealed?: () => void;
|
||||||
@@ -71,6 +81,62 @@ export interface LocalReconciliationCompletionDependencies {
|
|||||||
readonly afterBackupCollected?: () => void;
|
readonly afterBackupCollected?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runHistoryProof(
|
||||||
|
command: Readonly<LocalReconciliationCompleteCommand>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
uid: number,
|
||||||
|
dependencies: LocalReconciliationCompletionDependencies,
|
||||||
|
): Promise<Readonly<RunHistoryProof> | null> {
|
||||||
|
const domain = terminal.plan.domains.find(
|
||||||
|
(selected) => selected.domain === 'run_history',
|
||||||
|
);
|
||||||
|
if (!domain) fail('run history domain is absent');
|
||||||
|
if (domain.action === 'no_effect') {
|
||||||
|
if (
|
||||||
|
command.options.runHistory !== null ||
|
||||||
|
command.request.runHistory !== null
|
||||||
|
) {
|
||||||
|
fail('no-effect completion must not carry run history authority');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
domain.action !== 'adapter_required' ||
|
||||||
|
command.options.runHistory === null ||
|
||||||
|
command.request.runHistory === null
|
||||||
|
) {
|
||||||
|
fail('run history domain is not terminally provable');
|
||||||
|
}
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.runHistory.runHistoryRoot,
|
||||||
|
uid,
|
||||||
|
'runHistoryRoot',
|
||||||
|
);
|
||||||
|
const history = await readLocalReconciliationRunHistoryTerminal(
|
||||||
|
{
|
||||||
|
deploymentRoot: command.options.deploymentRoot,
|
||||||
|
applicationRoot: command.options.applicationRoot,
|
||||||
|
runHistoryRoot: command.options.runHistory.runHistoryRoot,
|
||||||
|
allowRootService: command.options.allowRootService,
|
||||||
|
},
|
||||||
|
command.request.runHistory.preservationId,
|
||||||
|
command.request.applicationId,
|
||||||
|
command.options.runHistory.decisionFilePath,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
history.receipt.preservationDigest !==
|
||||||
|
command.request.runHistory.expectedPreservationDigest ||
|
||||||
|
history.receipt.applicationPlanDigest !==
|
||||||
|
terminal.plan.applicationPlanDigest ||
|
||||||
|
history.receipt.sourceHeadDigest !== command.request.expectedHeadDigest
|
||||||
|
) {
|
||||||
|
fail('run history preservation evidence is detached');
|
||||||
|
}
|
||||||
|
return Object.freeze({ receipt: history.receipt });
|
||||||
|
}
|
||||||
|
|
||||||
function fail(message: string, cause?: unknown): never {
|
function fail(message: string, cause?: unknown): never {
|
||||||
throw new LocalDeploymentConfigurationError(
|
throw new LocalDeploymentConfigurationError(
|
||||||
`reconciliation completion ${message}`,
|
`reconciliation completion ${message}`,
|
||||||
@@ -393,6 +459,7 @@ async function automationProof(
|
|||||||
function domainEvidence(
|
function domainEvidence(
|
||||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
automation: Readonly<AutomationProof> | null,
|
automation: Readonly<AutomationProof> | null,
|
||||||
|
runHistory: Readonly<RunHistoryProof> | null,
|
||||||
): readonly Readonly<LocalReconciliationCompletionDomainEvidence>[] {
|
): readonly Readonly<LocalReconciliationCompletionDomainEvidence>[] {
|
||||||
return Object.freeze(
|
return Object.freeze(
|
||||||
terminal.plan.domains.map((domain) => {
|
terminal.plan.domains.map((domain) => {
|
||||||
@@ -416,6 +483,18 @@ function domainEvidence(
|
|||||||
evidenceDigest: automation.receipt.applyDigest,
|
evidenceDigest: automation.receipt.applyDigest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
domain.domain === 'run_history' &&
|
||||||
|
domain.action === 'adapter_required' &&
|
||||||
|
runHistory !== null
|
||||||
|
) {
|
||||||
|
return Object.freeze({
|
||||||
|
domain: domain.domain,
|
||||||
|
action: 'adapter_required' as const,
|
||||||
|
evidenceKind: 'run_history_preservation' as const,
|
||||||
|
evidenceDigest: runHistory.receipt.preservationDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
return fail(`${domain.domain} is not terminally reconciled`);
|
return fail(`${domain.domain} is not terminally reconciled`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -539,7 +618,13 @@ export async function completeLocalReconciliation(
|
|||||||
uid,
|
uid,
|
||||||
dependencies,
|
dependencies,
|
||||||
);
|
);
|
||||||
const domains = domainEvidence(terminal, automation);
|
const runHistory = await runHistoryProof(
|
||||||
|
command,
|
||||||
|
terminal,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
const domains = domainEvidence(terminal, automation, runHistory);
|
||||||
const selected = ensureCompletionDirectory(
|
const selected = ensureCompletionDirectory(
|
||||||
command.options.completionRoot,
|
command.options.completionRoot,
|
||||||
command.request.completionId,
|
command.request.completionId,
|
||||||
@@ -572,10 +657,11 @@ export async function completeLocalReconciliation(
|
|||||||
assertSourceHead(head, command.request.expectedHeadDigest, automation);
|
assertSourceHead(head, command.request.expectedHeadDigest, automation);
|
||||||
const adapterCount = domains.filter(
|
const adapterCount = domains.filter(
|
||||||
(domain) => domain.action === 'adapter_required',
|
(domain) => domain.action === 'adapter_required',
|
||||||
).length as 0 | 1;
|
).length as 0 | 1 | 2;
|
||||||
const latestEvidenceAtMs = Math.max(
|
const latestEvidenceAtMs = Math.max(
|
||||||
terminal.plan.committedAtMs,
|
terminal.plan.committedAtMs,
|
||||||
automation?.receipt.appliedAtMs ?? 0,
|
automation?.receipt.appliedAtMs ?? 0,
|
||||||
|
runHistory?.receipt.preservedAtMs ?? 0,
|
||||||
);
|
);
|
||||||
if (command.request.completedAtMs < latestEvidenceAtMs) {
|
if (command.request.completedAtMs < latestEvidenceAtMs) {
|
||||||
fail('completion timestamp precedes terminal evidence');
|
fail('completion timestamp precedes terminal evidence');
|
||||||
@@ -682,7 +768,7 @@ export async function verifyLocalReconciliationCompletion(
|
|||||||
command.options.applicationRoot,
|
command.options.applicationRoot,
|
||||||
);
|
);
|
||||||
const syntheticCompleteCommand = Object.freeze({
|
const syntheticCompleteCommand = Object.freeze({
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: receipt.schemaVersion,
|
||||||
operation: 'local.deployment.reconciliation.complete' as const,
|
operation: 'local.deployment.reconciliation.complete' as const,
|
||||||
options: command.options,
|
options: command.options,
|
||||||
request: Object.freeze({
|
request: Object.freeze({
|
||||||
@@ -691,6 +777,7 @@ export async function verifyLocalReconciliationCompletion(
|
|||||||
expectedApplicationPlanDigest: receipt.applicationPlanDigest,
|
expectedApplicationPlanDigest: receipt.applicationPlanDigest,
|
||||||
expectedHeadDigest: receipt.sourceHeadDigest,
|
expectedHeadDigest: receipt.sourceHeadDigest,
|
||||||
automation: command.request.automation,
|
automation: command.request.automation,
|
||||||
|
runHistory: command.request.runHistory,
|
||||||
completedAtMs: receipt.completedAtMs,
|
completedAtMs: receipt.completedAtMs,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -700,7 +787,13 @@ export async function verifyLocalReconciliationCompletion(
|
|||||||
uid,
|
uid,
|
||||||
dependencies,
|
dependencies,
|
||||||
);
|
);
|
||||||
const domains = domainEvidence(terminal, automation);
|
const runHistory = await runHistoryProof(
|
||||||
|
syntheticCompleteCommand,
|
||||||
|
terminal,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
const domains = domainEvidence(terminal, automation, runHistory);
|
||||||
validateReceiptBinding(
|
validateReceiptBinding(
|
||||||
receipt,
|
receipt,
|
||||||
terminal,
|
terminal,
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ const UUID_V4 =
|
|||||||
export interface LocalReconciliationCompletionDomainEvidence {
|
export interface LocalReconciliationCompletionDomainEvidence {
|
||||||
readonly domain: LocalReconciliationPlanDomain;
|
readonly domain: LocalReconciliationPlanDomain;
|
||||||
readonly action: 'no_effect' | 'adapter_required';
|
readonly action: 'no_effect' | 'adapter_required';
|
||||||
readonly evidenceKind: 'application_summary' | 'automation_apply';
|
readonly evidenceKind:
|
||||||
|
| 'application_summary'
|
||||||
|
| 'automation_apply'
|
||||||
|
| 'run_history_preservation';
|
||||||
readonly evidenceDigest: string;
|
readonly evidenceDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationCompletionReceipt {
|
export interface LocalReconciliationCompletionReceipt {
|
||||||
readonly schema: typeof RECEIPT_SCHEMA;
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1 | 2;
|
||||||
readonly state: 'reconciliation_completed';
|
readonly state: 'reconciliation_completed';
|
||||||
readonly completionId: string;
|
readonly completionId: string;
|
||||||
readonly applicationId: string;
|
readonly applicationId: string;
|
||||||
@@ -31,7 +34,7 @@ export interface LocalReconciliationCompletionReceipt {
|
|||||||
readonly applicationPlanDigest: string;
|
readonly applicationPlanDigest: string;
|
||||||
readonly sourceHeadDigest: string;
|
readonly sourceHeadDigest: string;
|
||||||
readonly domains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[];
|
readonly domains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[];
|
||||||
readonly adapterCount: 0 | 1;
|
readonly adapterCount: 0 | 1 | 2;
|
||||||
readonly completedAtMs: number;
|
readonly completedAtMs: number;
|
||||||
readonly completionDigest: string;
|
readonly completionDigest: string;
|
||||||
}
|
}
|
||||||
@@ -65,6 +68,7 @@ function exact(
|
|||||||
function domainEvidence(
|
function domainEvidence(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
expectedDomain: LocalReconciliationPlanDomain,
|
expectedDomain: LocalReconciliationPlanDomain,
|
||||||
|
schemaVersion: 1 | 2,
|
||||||
): Readonly<LocalReconciliationCompletionDomainEvidence> {
|
): Readonly<LocalReconciliationCompletionDomainEvidence> {
|
||||||
const selected = exact(
|
const selected = exact(
|
||||||
value,
|
value,
|
||||||
@@ -78,9 +82,14 @@ function domainEvidence(
|
|||||||
expectedDomain === 'automation' &&
|
expectedDomain === 'automation' &&
|
||||||
selected.action === 'adapter_required' &&
|
selected.action === 'adapter_required' &&
|
||||||
selected.evidenceKind === 'automation_apply';
|
selected.evidenceKind === 'automation_apply';
|
||||||
|
const runHistory =
|
||||||
|
schemaVersion === 2 &&
|
||||||
|
expectedDomain === 'run_history' &&
|
||||||
|
selected.action === 'adapter_required' &&
|
||||||
|
selected.evidenceKind === 'run_history_preservation';
|
||||||
if (
|
if (
|
||||||
selected.domain !== expectedDomain ||
|
selected.domain !== expectedDomain ||
|
||||||
(!noEffect && !automation) ||
|
(!noEffect && !automation && !runHistory) ||
|
||||||
typeof selected.evidenceDigest !== 'string' ||
|
typeof selected.evidenceDigest !== 'string' ||
|
||||||
!DIGEST.test(selected.evidenceDigest)
|
!DIGEST.test(selected.evidenceDigest)
|
||||||
) {
|
) {
|
||||||
@@ -100,9 +109,14 @@ export function buildLocalReconciliationCompletionReceipt(
|
|||||||
'schema' | 'schemaVersion' | 'state' | 'completionDigest'
|
'schema' | 'schemaVersion' | 'state' | 'completionDigest'
|
||||||
>,
|
>,
|
||||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||||
|
const schemaVersion = input.domains.some(
|
||||||
|
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||||
|
)
|
||||||
|
? (2 as const)
|
||||||
|
: (1 as const);
|
||||||
const payload = Object.freeze({
|
const payload = Object.freeze({
|
||||||
schema: RECEIPT_SCHEMA,
|
schema: RECEIPT_SCHEMA,
|
||||||
schemaVersion: 1 as const,
|
schemaVersion,
|
||||||
state: 'reconciliation_completed' as const,
|
state: 'reconciliation_completed' as const,
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
@@ -140,10 +154,14 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
|||||||
if (!Array.isArray(selected.domains) || selected.domains.length !== 8) {
|
if (!Array.isArray(selected.domains) || selected.domains.length !== 8) {
|
||||||
fail('receipt domain catalog is invalid');
|
fail('receipt domain catalog is invalid');
|
||||||
}
|
}
|
||||||
|
if (selected.schemaVersion !== 1 && selected.schemaVersion !== 2) {
|
||||||
|
fail('receipt schema version is invalid');
|
||||||
|
}
|
||||||
|
const schemaVersion = selected.schemaVersion;
|
||||||
const rawDomains = selected.domains as unknown[];
|
const rawDomains = selected.domains as unknown[];
|
||||||
const domains = Object.freeze(
|
const domains = Object.freeze(
|
||||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||||
domainEvidence(rawDomains[index], domain),
|
domainEvidence(rawDomains[index], domain, schemaVersion),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const adapterCount = domains.filter(
|
const adapterCount = domains.filter(
|
||||||
@@ -153,7 +171,10 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
|||||||
const normalized = Object.freeze({ ...raw, domains });
|
const normalized = Object.freeze({ ...raw, domains });
|
||||||
if (
|
if (
|
||||||
selected.schema !== RECEIPT_SCHEMA ||
|
selected.schema !== RECEIPT_SCHEMA ||
|
||||||
selected.schemaVersion !== 1 ||
|
(schemaVersion === 2) !==
|
||||||
|
domains.some(
|
||||||
|
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||||
|
) ||
|
||||||
selected.state !== 'reconciliation_completed' ||
|
selected.state !== 'reconciliation_completed' ||
|
||||||
typeof selected.completionId !== 'string' ||
|
typeof selected.completionId !== 'string' ||
|
||||||
!UUID_V4.test(selected.completionId) ||
|
!UUID_V4.test(selected.completionId) ||
|
||||||
@@ -174,7 +195,7 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
|||||||
].every(
|
].every(
|
||||||
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||||
) ||
|
) ||
|
||||||
(selected.adapterCount !== 0 && selected.adapterCount !== 1) ||
|
![0, 1, 2].includes(selected.adapterCount as number) ||
|
||||||
selected.adapterCount !== adapterCount ||
|
selected.adapterCount !== adapterCount ||
|
||||||
!Number.isSafeInteger(selected.completedAtMs) ||
|
!Number.isSafeInteger(selected.completedAtMs) ||
|
||||||
(selected.completedAtMs as number) < 0 ||
|
(selected.completedAtMs as number) < 0 ||
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type LocalReconciliationDiagnosticReason =
|
|||||||
| 'reviewable_fact'
|
| 'reviewable_fact'
|
||||||
| 'unknown_schema'
|
| 'unknown_schema'
|
||||||
| 'secret_custody_required'
|
| 'secret_custody_required'
|
||||||
|
| 'historical_preservation_required'
|
||||||
| 'historical_integrity_required'
|
| 'historical_integrity_required'
|
||||||
| 'identity_custody_required';
|
| 'identity_custody_required';
|
||||||
|
|
||||||
@@ -89,7 +90,83 @@ function quotedIdentifier(name: string): string {
|
|||||||
return `"${name.replaceAll('"', '""')}"`;
|
return `"${name.replaceAll('"', '""')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
function targetRunHistoryIsTerminal(client: DatabaseSync): boolean {
|
||||||
|
const tables = new Map<string, Set<string>>();
|
||||||
|
for (const tableName of ['Runs', 'RunAttempts', 'StepRuns'] as const) {
|
||||||
|
const table = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS present
|
||||||
|
FROM sqlite_schema
|
||||||
|
WHERE type = 'table' AND name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get(tableName) as { readonly present?: unknown } | undefined;
|
||||||
|
if (table?.present !== 1) continue;
|
||||||
|
const columns = new Set<string>();
|
||||||
|
const rows = client
|
||||||
|
.prepare(`SELECT name FROM pragma_table_info(?) ORDER BY cid LIMIT 257`)
|
||||||
|
.iterate(tableName) as IterableIterator<{ readonly name?: unknown }>;
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
count += 1;
|
||||||
|
if (
|
||||||
|
count > 256 ||
|
||||||
|
typeof row.name !== 'string' ||
|
||||||
|
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
columns.add(row.name);
|
||||||
|
}
|
||||||
|
tables.set(tableName, columns);
|
||||||
|
}
|
||||||
|
const runs = tables.get('Runs');
|
||||||
|
if (!runs?.has('status') || !runs.has('finished_at_ms')) return false;
|
||||||
|
const activeRun = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS present
|
||||||
|
FROM "Runs"
|
||||||
|
WHERE status NOT IN ('succeeded','failed','cancelled','timed_out')
|
||||||
|
OR finished_at_ms IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get() as { readonly present?: unknown } | undefined;
|
||||||
|
if (activeRun?.present === 1) return false;
|
||||||
|
const attempts = tables.get('RunAttempts');
|
||||||
|
if (attempts !== undefined) {
|
||||||
|
if (!attempts.has('status')) return false;
|
||||||
|
const activeAttempt = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS present
|
||||||
|
FROM "RunAttempts"
|
||||||
|
WHERE status IN ('claimed','starting','running')
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get() as { readonly present?: unknown } | undefined;
|
||||||
|
if (activeAttempt?.present === 1) return false;
|
||||||
|
}
|
||||||
|
const steps = tables.get('StepRuns');
|
||||||
|
if (steps !== undefined) {
|
||||||
|
if (!steps.has('status') || !steps.has('finished_at_ms')) return false;
|
||||||
|
const activeStep = client
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS present
|
||||||
|
FROM "StepRuns"
|
||||||
|
WHERE status NOT IN ('succeeded','failed','skipped','cancelled','timed_out')
|
||||||
|
OR finished_at_ms IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get() as { readonly present?: unknown } | undefined;
|
||||||
|
if (activeStep?.present === 1) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirement(
|
||||||
|
client: DatabaseSync,
|
||||||
|
database: LocalReconciliationSealedDatabaseKind,
|
||||||
|
domain: LocalReconciliationPlanDomain,
|
||||||
|
): Readonly<{
|
||||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||||
reason: LocalReconciliationDiagnosticReason;
|
reason: LocalReconciliationDiagnosticReason;
|
||||||
}> {
|
}> {
|
||||||
@@ -106,6 +183,12 @@ function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (domain === 'run_history') {
|
if (domain === 'run_history') {
|
||||||
|
if (database === 'legacy' || targetRunHistoryIsTerminal(client)) {
|
||||||
|
return Object.freeze({
|
||||||
|
decisionRequirement: 'required' as const,
|
||||||
|
reason: 'historical_preservation_required' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
decisionRequirement: 'blocked' as const,
|
decisionRequirement: 'blocked' as const,
|
||||||
reason: 'historical_integrity_required' as const,
|
reason: 'historical_integrity_required' as const,
|
||||||
@@ -161,8 +244,11 @@ function fact(
|
|||||||
name: string,
|
name: string,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
count: string | null,
|
count: string | null,
|
||||||
|
review: Readonly<{
|
||||||
|
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||||
|
reason: LocalReconciliationDiagnosticReason;
|
||||||
|
}>,
|
||||||
): Readonly<LocalReconciliationDiagnosticFact> {
|
): Readonly<LocalReconciliationDiagnosticFact> {
|
||||||
const review = requirement(domain);
|
|
||||||
const payload = Object.freeze({
|
const payload = Object.freeze({
|
||||||
schema: FACT_SCHEMA,
|
schema: FACT_SCHEMA,
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: 1 as const,
|
||||||
@@ -190,6 +276,7 @@ function schemaFacts(
|
|||||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
const review = requirement(client, database, domain);
|
||||||
const rows = client
|
const rows = client
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT type, name, tbl_name AS tableName
|
`SELECT type, name, tbl_name AS tableName
|
||||||
@@ -240,6 +327,7 @@ function schemaFacts(
|
|||||||
row.name,
|
row.name,
|
||||||
row.tableName,
|
row.tableName,
|
||||||
null,
|
null,
|
||||||
|
review,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -256,6 +344,7 @@ function tableFacts(
|
|||||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
const review = requirement(client, database, domain);
|
||||||
const rows = client
|
const rows = client
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT name, type
|
`SELECT name, type
|
||||||
@@ -302,6 +391,7 @@ function tableFacts(
|
|||||||
row.name,
|
row.name,
|
||||||
row.name,
|
row.name,
|
||||||
rowCount(client, domain, row.name, row.type),
|
rowCount(client, domain, row.name, row.type),
|
||||||
|
review,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const {
|
|||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
prepareLocalReconciliationApplication,
|
prepareLocalReconciliationApplication,
|
||||||
prepareLocalReconciliationAutomationDecision,
|
prepareLocalReconciliationAutomationDecision,
|
||||||
|
preserveLocalReconciliationRunHistory,
|
||||||
readLocalReconciliationAutomationDecisionTerminal,
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
rollbackLocalReconciliationAutomationApply,
|
rollbackLocalReconciliationAutomationApply,
|
||||||
planLocalReconciliationAutomation,
|
planLocalReconciliationAutomation,
|
||||||
@@ -31,6 +32,7 @@ const {
|
|||||||
verifyLocalReconciliationCompletion,
|
verifyLocalReconciliationCompletion,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
|
verifyLocalReconciliationRunHistory,
|
||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
} = require('../dist/deployment/localDeployment.js');
|
} = require('../dist/deployment/localDeployment.js');
|
||||||
const {
|
const {
|
||||||
@@ -710,6 +712,50 @@ function automationDatabaseInitializer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runHistoryDatabaseInitializer() {
|
||||||
|
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
|
||||||
|
const legacy = new DatabaseSync(legacySourcePath);
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE "CrontabStats" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "CrontabStats" (id, timestamp, status)
|
||||||
|
VALUES (1, 1000, 0);
|
||||||
|
`);
|
||||||
|
legacy.close();
|
||||||
|
fs.chmodSync(legacySourcePath, 0o600);
|
||||||
|
fs.copyFileSync(legacySourcePath, recoveryPath);
|
||||||
|
fs.chmodSync(recoveryPath, 0o600);
|
||||||
|
|
||||||
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
|
target.exec(`
|
||||||
|
CREATE TABLE "Runs" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
finished_at_ms INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO "Runs" (id, status, finished_at_ms)
|
||||||
|
VALUES ('baseline', 'succeeded', 1000);
|
||||||
|
`);
|
||||||
|
target.close();
|
||||||
|
fs.chmodSync(targetDatabasePath, 0o600);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateRunHistoryTarget({ targetDatabasePath }, status = 'failed') {
|
||||||
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
|
target
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "Runs" (id, status, finished_at_ms)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run('captured', status, status === 'running' ? null : 2000);
|
||||||
|
target.close();
|
||||||
|
return Object.freeze({});
|
||||||
|
}
|
||||||
|
|
||||||
function automationReadyDatabaseInitializer() {
|
function automationReadyDatabaseInitializer() {
|
||||||
const initializeLegacy = automationDatabaseInitializer();
|
const initializeLegacy = automationDatabaseInitializer();
|
||||||
return (paths) => {
|
return (paths) => {
|
||||||
@@ -3244,6 +3290,303 @@ test('completion fence retains automation rollback backup while other domains re
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('run history preservation seals terminal histories and completes through v2 evidence', async (t) => {
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000501',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000502',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000503',
|
||||||
|
reviewSuffix: 'run-history-preservation',
|
||||||
|
createDefaultSidecars: false,
|
||||||
|
initializeDatabases: runHistoryDatabaseInitializer(),
|
||||||
|
mutateTarget(paths) {
|
||||||
|
return mutateRunHistoryTarget(paths);
|
||||||
|
},
|
||||||
|
mutateDecisions(records) {
|
||||||
|
const selected = records.filter(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||||
|
record.database === 'legacy' &&
|
||||||
|
record.domain === 'run_history',
|
||||||
|
);
|
||||||
|
assert.ok(selected.length > 0);
|
||||||
|
for (const record of selected) {
|
||||||
|
record.disposition = 'retain_both';
|
||||||
|
record.reason = 'preserve_both';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
const application = await commitLocalReconciliationApplication(
|
||||||
|
applicationCommitCommand(state, prepared),
|
||||||
|
);
|
||||||
|
assert.equal(application.outcome, 'adapter_required');
|
||||||
|
const applicationPlan = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(state.applicationRoot, application.applicationId, 'plan.json'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
applicationPlan.domains.find((domain) => domain.domain === 'run_history')
|
||||||
|
.action,
|
||||||
|
'adapter_required',
|
||||||
|
);
|
||||||
|
|
||||||
|
const runHistoryRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'run-history-preservation-root',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(runHistoryRoot, { mode: 0o700 });
|
||||||
|
const preservationCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.preserve',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
runHistoryRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
preservationId: '00000000-0000-4000-8000-000000000504',
|
||||||
|
applicationId: application.applicationId,
|
||||||
|
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: application.instanceHeadDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
preservedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const boundary of ['afterReceiptPublished', 'afterTerminalSealed']) {
|
||||||
|
await assert.rejects(
|
||||||
|
preserveLocalReconciliationRunHistory(preservationCommand, {
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`run history ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`run history ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const preserved = await preserveLocalReconciliationRunHistory(
|
||||||
|
preservationCommand,
|
||||||
|
);
|
||||||
|
assert.equal(preserved.status, 'existing');
|
||||||
|
assert.ok(preserved.legacyFactCount > 0);
|
||||||
|
assert.ok(preserved.targetFactCount > 0);
|
||||||
|
const preservationDirectory = path.join(
|
||||||
|
runHistoryRoot,
|
||||||
|
preservationCommand.request.preservationId,
|
||||||
|
);
|
||||||
|
assert.deepEqual(fs.readdirSync(preservationDirectory), ['receipt.json']);
|
||||||
|
assert.equal(fs.statSync(preservationDirectory).mode & 0o777, 0o500);
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(preservationDirectory, 'receipt.json')).mode & 0o777,
|
||||||
|
0o400,
|
||||||
|
);
|
||||||
|
const preservationReceiptText = fs.readFileSync(
|
||||||
|
path.join(preservationDirectory, 'receipt.json'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.equal(preservationReceiptText.includes('CrontabStats'), false);
|
||||||
|
assert.equal(preservationReceiptText.includes('captured'), false);
|
||||||
|
assert.equal(
|
||||||
|
preservationReceiptText.includes(state.reviewFile.filePath),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const preservationVerifyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.verify',
|
||||||
|
options: preservationCommand.options,
|
||||||
|
request: {
|
||||||
|
preservationId: preservationCommand.request.preservationId,
|
||||||
|
applicationId: preservationCommand.request.applicationId,
|
||||||
|
expectedPreservationDigest: preserved.preservationDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const preservationVerified = await verifyLocalReconciliationRunHistory(
|
||||||
|
preservationVerifyCommand,
|
||||||
|
);
|
||||||
|
assert.equal(preservationVerified.status, 'verified');
|
||||||
|
|
||||||
|
const completionRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'completion-run-history',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||||
|
const runHistory = {
|
||||||
|
preservationId: preservationCommand.request.preservationId,
|
||||||
|
expectedPreservationDigest: preserved.preservationDigest,
|
||||||
|
};
|
||||||
|
const completionCommand = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
operation: 'local.deployment.reconciliation.complete',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
completionRoot,
|
||||||
|
automation: null,
|
||||||
|
runHistory: {
|
||||||
|
runHistoryRoot,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
},
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
completionId: '00000000-0000-4000-8000-000000000505',
|
||||||
|
applicationId: application.applicationId,
|
||||||
|
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: application.instanceHeadDigest,
|
||||||
|
automation: null,
|
||||||
|
runHistory,
|
||||||
|
completedAtMs: preservationCommand.request.preservedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const completed = await completeLocalReconciliation(completionCommand);
|
||||||
|
assert.equal(completed.adapterCount, 1);
|
||||||
|
const completionReceipt = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
completionRoot,
|
||||||
|
completionCommand.request.completionId,
|
||||||
|
'receipt.json',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(completionReceipt.schemaVersion, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
completionReceipt.domains.find((domain) => domain.domain === 'run_history'),
|
||||||
|
{
|
||||||
|
domain: 'run_history',
|
||||||
|
action: 'adapter_required',
|
||||||
|
evidenceKind: 'run_history_preservation',
|
||||||
|
evidenceDigest: preserved.preservationDigest,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const completionVerified = await verifyLocalReconciliationCompletion({
|
||||||
|
schemaVersion: 2,
|
||||||
|
operation: 'local.deployment.reconciliation.complete.verify',
|
||||||
|
options: completionCommand.options,
|
||||||
|
request: {
|
||||||
|
completionId: completionCommand.request.completionId,
|
||||||
|
applicationId: completionCommand.request.applicationId,
|
||||||
|
expectedCompletionDigest: completed.completionDigest,
|
||||||
|
automation: null,
|
||||||
|
runHistory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(completionVerified.status, 'verified');
|
||||||
|
|
||||||
|
const commandPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'run-history-verify.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
commandPath,
|
||||||
|
`${JSON.stringify(preservationVerifyCommand)}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const cli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-run-history-verify',
|
||||||
|
'--command-file',
|
||||||
|
commandPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(cli.status, 0, cli.stderr);
|
||||||
|
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||||
|
assert.equal(cli.stdout.includes(runHistoryRoot), false);
|
||||||
|
assert.equal(cli.stdout.includes('CrontabStats'), false);
|
||||||
|
|
||||||
|
const preservationReceiptPath = path.join(
|
||||||
|
preservationDirectory,
|
||||||
|
'receipt.json',
|
||||||
|
);
|
||||||
|
const tamperedReceipt = JSON.parse(
|
||||||
|
fs.readFileSync(preservationReceiptPath, 'utf8'),
|
||||||
|
);
|
||||||
|
tamperedReceipt.legacyFactCount += 1;
|
||||||
|
fs.chmodSync(preservationDirectory, 0o700);
|
||||||
|
fs.chmodSync(preservationReceiptPath, 0o600);
|
||||||
|
fs.writeFileSync(
|
||||||
|
preservationReceiptPath,
|
||||||
|
`${JSON.stringify(tamperedReceipt, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
fs.chmodSync(preservationReceiptPath, 0o400);
|
||||||
|
fs.chmodSync(preservationDirectory, 0o500);
|
||||||
|
await assert.rejects(
|
||||||
|
verifyLocalReconciliationRunHistory(preservationVerifyCommand),
|
||||||
|
/receipt binding is invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run history preservation rejects active target runs and review escalation', async (t) => {
|
||||||
|
const state = reviewCommitFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000511',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000512',
|
||||||
|
reviewSuffix: 'run-history-active',
|
||||||
|
createDefaultSidecars: false,
|
||||||
|
initializeDatabases: runHistoryDatabaseInitializer(),
|
||||||
|
mutateTarget(paths) {
|
||||||
|
return mutateRunHistoryTarget(paths, 'running');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pageCommand = diagnosticCommand(state, state.prepared, {
|
||||||
|
database: 'target',
|
||||||
|
domain: 'run_history',
|
||||||
|
factKind: 'table',
|
||||||
|
outputName: 'active-run-history.json',
|
||||||
|
});
|
||||||
|
writeLocalReconciliationReviewDiagnostics(pageCommand);
|
||||||
|
const page = JSON.parse(
|
||||||
|
fs.readFileSync(pageCommand.request.outputPath, 'utf8'),
|
||||||
|
);
|
||||||
|
assert.ok(page.records.length > 0);
|
||||||
|
assert.equal(
|
||||||
|
page.records.every(
|
||||||
|
(fact) =>
|
||||||
|
fact.decisionRequirement === 'blocked' &&
|
||||||
|
fact.reason === 'historical_integrity_required',
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const selected = state.reviewFile.records.find(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||||
|
record.database === 'target' &&
|
||||||
|
record.domain === 'run_history',
|
||||||
|
);
|
||||||
|
assert.ok(selected);
|
||||||
|
selected.disposition = 'retain_target';
|
||||||
|
selected.reason = 'preserve_target';
|
||||||
|
fs.writeFileSync(
|
||||||
|
state.reviewFile.filePath,
|
||||||
|
`${state.reviewFile.records
|
||||||
|
.map((record) => JSON.stringify(record))
|
||||||
|
.join('\n')}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationReview(state.command, state.dependencies),
|
||||||
|
/decision disposition is not allowed for canonical fact/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(
|
||||||
|
state.reviewRoot,
|
||||||
|
state.reviewCommand.request.reviewId,
|
||||||
|
'review.json',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('automation adapter builds a sealed row plan with bounded conflict evidence', async (t) => {
|
test('automation adapter builds a sealed row plan with bounded conflict evidence', async (t) => {
|
||||||
const state = await reviewedApplicationFixture(t, {
|
const state = await reviewedApplicationFixture(t, {
|
||||||
planId: '00000000-0000-4000-8000-000000000431',
|
planId: '00000000-0000-4000-8000-000000000431',
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 172,
|
sourceFiles: 175,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 171,
|
nestedSourceFiles: 174,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user