feat(ql3): prepare service manager legacy rollback

This commit is contained in:
whyour
2026-08-20 16:34:30 +08:00
parent 0c503932cc
commit ad291e514d
11 changed files with 1058 additions and 13 deletions
+19
View File
@@ -11,6 +11,25 @@
最新增量证据(2026-08-20):
- D-379/ADR-0472(已接受;OpenRC live actor 待镜像基础设施恢复后补跑):为 systemd/OpenRC adopted cutover 增加 Owner-side
`service-legacy-rollback-prepare`。命令绑定 exact `target_stopped` record 与 instance head,重读 stop intent、Application v3、
activation 和 legacy-silence commitment,并复用 Docker 路径相同的 stable-fd SHA-256/inode/sidecar reconciliation。
只有 target 等于 activation、source 等于 recovery 且双方 SQLite sidecar clear 的 `rollback_candidate` 才 no-replace 发布
digest-bound preparation 并 CAS 到 `rollback_prepared`;target 写后或证据不确定返回结构化 `not-prepared`,保持
`target_stopped`,不错误进入 terminal `manual_required`。本切片位于现有 Local Owner 包,不新增 workspace package、
production dependency、数据库连接、daemon、watcher、timer 或 root mutationroot-only systemd/OpenRC legacy start、
inspect-only crash convergence、最终 `legacy_running` 消费与 2.x health proof 留给下一阶段。定向 prepare 门 `10/10`Local
Owner 全量 `175 total / 170 pass / 5 conditional skip / 0 fail`,完整 backend 为
`1,507 total / 1,505 pass / 2 conditional skip / 0 fail`18-package clean build/逐包测试单次退出 0。package boundary、
Service Bridge import、Edge import、Cluster dependency、Cluster/Worker deployment、Console 与 Console distribution 八项审计
全部 compatible/passedworkspace 仍为 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Local Owner 为
`108 source / 107 nested / 1 root binary entry`。14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 保持
`2,598,669 / 2,598,747` bytes、316 files、57 loaded modulesAdopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modules
Application+AI 为 `4,501,822 / 4,501,954` bytesMCP 为 `7,324,601 / 7,324,709` bytes。Docker live gate 已完成 systemd
root/non-root 两个 actor 的真实 stop、prepare 与 exact replayOpenRC 两个 actor 因 `node:24-alpine` registry mirror EOF 与
credential helper 挂起未执行,不能宣称四组合门已闭合,镜像基础设施恢复后须补跑。本切片不改变 PostgreSQL schema、ACL、
repository、role、Pool、连接或 failover 语义,因此不重跑且不重新占有 HA 证明;D-373/D-374 PostgreSQL 18.6 arm64 HA
`146/146`、timeline `1→2` 仅作为相邻既有基线。
- D-378/ADR-0471(已接受):把孵化重点从连续 Cluster Console 增量转回 3.0 首发兼容闭环,新增直接运行生产 Cron/Subscription
Express Router 与 Celebrate validator 的 2.x 核心执行 API 基线。Cron 覆盖 list/create/update/disable/enable/run/stop、单日志、日志列表和
单实例 stopSubscription 覆盖 list/create/update/disable/enable/run/stop/log。确定性 TypeDI spy 同时锁定服务参数与现行 JSON envelope,尤其
@@ -0,0 +1,98 @@
# ADR-0472Service Manager Legacy Rollback Preparation
- 状态:Accepted
- 日期:2026-08-20
- 关联 RFCQL-RFC-0001 D-64、D-274、D-275、D-379
- 关联 ADRADR-0314、ADR-0315、ADR-0362、ADR-0363
## 背景
QingLong 3.0 的 Docker adopted cutover 已有 `target_stopped → rollback_prepared → legacy_running` 双阶段回退,
但 systemd/OpenRC 双 authority 路径只做到 `target_stopped`。该记录证明 3.0 service 已停止、shutdown receipt 与
进程身份闭合,却没有重新计算 target/source SQLite 的哈希、稳定 inode 与 `-wal|-shm|-journal` sidecar,也没有一份
可由 root bridge 消费的持久回退授权。
直接复用 Docker rollback coordinator 不成立:Docker controller 与数据文件同属 Owner authority,而 systemd/OpenRC
需要把 Owner 私有状态判定与 root-only init mutation 分开。与此同时,低配路由器不能为回退增加数据库连接、常驻
supervisor、watcher 或第二套 SQLite 语义。
编辑前 GitNexus 对共享 reconciliation reader、service cutover consumer、cutover record parser 与 CLI main 的影响均为
LOW:最多 2 个直接调用者、6 个累计上游符号、0 条已识别 execution flow。
## 决策
### 1. 先关闭 Owner-side prepare,不在本阶段执行 root mutation
在现有 `@qinglong/local-owner-cli` 包内增加:
```text
ql3-local-deploy service-legacy-rollback-prepare --command-file <private.json>
```
命令必须提交 exact `cutoverId/profile/instanceId/generation/activationDigest``target_stopped` record digest 与
instance head digest。Owner 重新读取并绑定:
- 当前实例 head 必须仍是相同 generation 的 `target_stopped`
- stop record 必须来自 action=`stop`,含有效 shutdown receipt 且没有 manual reason
- 原 stop intent、Application v3 config、activation 与 legacy-silence commitment 的 digest/路径/lineage 必须一致;
- target/source SQLite 必须在同一稳定 fd 快照内完成 SHA-256inode/path/size/mtime 与 sidecar 状态必须闭合。
只有 `targetMatchesActivation=true``sourceMatchesRecovery=true` 且双方 sidecar clear 时,disposition 才是
`rollback_candidate`,命令才 no-replace 发布
`service-manager-gNN-rollback-prepared.json` 并把 instance head CAS 到 `rollback_prepared`。prepare 不执行
start/stop/restart/enable,不修改 SQLite,也不读取 Docker socket。
### 2. 非候选不污染实例终态
target 已产生写入或存在 target sidecar 时返回 `not-prepared/reconciliation_required`source 与 recovery 不一致或证据
无法稳定读取时返回 `not-prepared/manual_review`。两者都保持原 `target_stopped` head,不写 preparation,不进入
`manual_required`。这是为了保留显式重启 3.0 或进入后续数据 reconciliation ceremony 的能力;回退不可用不等于实例
已不可恢复。
### 3. Docker 与 service-manager 共用同一 reconciliation 实现
将既有 reader 抽成按 `profile/activation/source/target/expectedActivationDigest` 采集的内部共享接口,Docker 原入口继续
委托它,保持现有调用与返回 contract。这样两类部署使用相同哈希、stable-fd、sidecar 和 fail-closed 规则,不复制一套
容易漂移的数据判定。
### 4. 保持 package 粒度
实现继续位于 `local-owner-cli/src/deployment/service-manager`,没有新增 workspace package、production dependency、
binary 制品或常驻组件。模块边界按 Owner prepare 责任划分;只有将来 root bridge 需要独立 OS 分发/签名/依赖闭包时,
才重新评估是否拆包。
## 被否决方案
1. **stop 成功后直接启动 legacy**:没有数据重验证和 durable authorization,拒绝。
2. **非 rollback candidate 直接写 manual_required**:会错误封死 3.0 正向恢复,拒绝。
3. **复制 Docker reconciliation 代码**:两套哈希/sidecar 语义会漂移,拒绝。
4. **让 root bridge 自己读取 Owner SQLite 并决定能否回退**:混淆数据 authority 与 init mutation authority,拒绝。
5. **为 prepare 新建 workspace package**:没有独立交付和依赖边界,不制造微包。
## 验收证据
- 定向 service cutover/rollback preparation `10/10`,覆盖成功、exact replay、target 写后拒绝、Application config 漂移与
legacy-silence commitment 漂移拒绝。
- Local Owner 完整测试为 `175 total / 170 pass / 5 conditional skip / 0 fail`18-package clean build/逐包测试单次退出 0
backend 全量为 `1,507 total / 1,505 pass / 2 conditional skip / 0 fail`
- package boundary、Service Bridge import、Edge import、Cluster dependency、Cluster/Worker deployment、Console 与 Console
distribution 八项审计全部 compatible/passed。workspace 保持 18 packages、`singleSourcePackages=[]`
`shallowSourcePackages=[]`Local Owner 为 `108 source / 107 nested / 1 root binary entry`,没有形成单文件或浅层微包。
- 14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,598,669 / 2,598,747` bytes、316 files、
57 loaded modulesAdopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modulesApplication+AI 为
`4,501,822 / 4,501,954` bytesMCP 为 `7,324,601 / 7,324,709` bytes。prepare 没有进入基础路由器闭包。
- Docker live gate 已依次完成 systemd root/non-root 两个 actor 的真实 stop、prepare 与 exact replay,随后在构建 OpenRC actor
前因 `node:24-alpine` registry mirror EOF/本机 credential helper 挂起而停止;因此本 ADR 不把 systemd/OpenRC 四组合门
记为完整通过,待镜像基础设施恢复后补跑 OpenRC root/non-root。测试容器和临时镜像均已清理。
- 本阶段不修改 SQL、migration、PostgreSQL repository/role/Pool 或 HA 语义,因此不重新生成 PostgreSQL HA 证据。
## 未完成
- root-only systemd/OpenRC legacy start barrier、响应丢失后的 inspect-only 收敛;
- Owner 对 legacy running、target stopped 与 preparation/data 未漂移的最终消费;
- `legacy_running` 后的 2.x readiness/health proof
- `reconciliation_required` 的 export、冲突裁决与受审回灌;
- 固定物理 Edge 的完整 prepare/commit/rollback 证据。
本 ADR 只关闭 service-manager 回退的 Owner prepare 阶段,不宣称 systemd/OpenRC legacy rollback controller 或
QingLong 3.0 升级/回退 Gate 已全部完成。
+1
View File
@@ -475,6 +475,7 @@
| [ADR-0469](./ADR-0469-optional-console-package-installation-observation.md) | 可选 Console Package Installation 只读观察 | Accepted |
| [ADR-0470](./ADR-0470-session-scoped-console-capability-discovery.md) | Console 会话级能力发现与服务端操作围栏 | Accepted |
| [ADR-0471](./ADR-0471-legacy-core-api-compatibility-baseline.md) | Legacy 核心执行 API 兼容基线 | Accepted |
| [ADR-0472](./ADR-0472-service-manager-legacy-rollback-preparation.md) | Service Manager Legacy Rollback Preparation | Accepted |
## 规则
@@ -22,6 +22,14 @@ export interface TargetDataReconciliationEvidence {
readonly evidenceDigest: string;
}
export interface TargetDataReconciliationInput {
readonly profile: 'edge' | 'standalone';
readonly activationPath: string;
readonly legacySourcePath: string;
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
}
interface FileEvidence {
readonly sha256: string;
readonly identityDigest: string;
@@ -150,22 +158,36 @@ function evidence(
export function readTargetDataReconciliationEvidence(
command: Readonly<LocalDeploymentTargetRunCommand>,
uid: number,
): Readonly<TargetDataReconciliationEvidence> {
return readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: command.request.activationPath,
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
},
uid,
);
}
export function readTargetDataReconciliationEvidenceForPaths(
input: Readonly<TargetDataReconciliationInput>,
uid: number,
): Readonly<TargetDataReconciliationEvidence> {
try {
const activation = object(
readPrivateLocalCommandFile(command.request.activationPath),
readPrivateLocalCommandFile(input.activationPath),
);
const { activationDigest, ...payload } = activation;
if (
activation.schemaVersion !== 1 ||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
activation.state !== 'prepared' ||
activation.profile !== command.request.profile ||
activation.sourcePathDigest !==
textDigest(command.request.legacySourcePath) ||
activation.targetPathDigest !==
textDigest(command.request.targetDatabasePath) ||
activationDigest !== command.request.expectedActivationDigest ||
activation.profile !== input.profile ||
activation.sourcePathDigest !== textDigest(input.legacySourcePath) ||
activation.targetPathDigest !== textDigest(input.targetDatabasePath) ||
activationDigest !== input.expectedActivationDigest ||
typeof activationDigest !== 'string' ||
!DIGEST_PATTERN.test(activationDigest) ||
typeof activation.targetSha256 !== 'string' ||
@@ -179,12 +201,12 @@ export function readTargetDataReconciliationEvidence(
throw new Error('activation identity drifted');
}
const target = fileEvidence(
command.request.targetDatabasePath,
input.targetDatabasePath,
uid,
'target database',
);
const source = fileEvidence(
command.request.legacySourcePath,
input.legacySourcePath,
uid,
'legacy source database',
);
@@ -79,6 +79,10 @@ import {
consumeLocalServiceManagerCutoverOutcome,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
} from './service-manager/serviceCutoverConsumer';
import {
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
} from './service-manager/serviceLegacyRollback';
export {
LocalDeploymentConfigurationError,
@@ -181,6 +185,8 @@ export {
consumeLocalServiceManagerCutoverOutcomeCommandFile,
prepareLocalServiceManagerIntent,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
};
export {
localServiceManagerIntentDigest,
@@ -209,6 +215,13 @@ export {
type LocalServiceManagerCutoverRecord,
type LocalServiceManagerCutoverState,
} from './service-manager/serviceCutoverJournal';
export {
localServiceManagerLegacyRollbackPreparationPath,
normalizeLocalServiceManagerLegacyRollbackPreparation,
type LocalServiceManagerLegacyRollbackPreparation,
type LocalServiceManagerLegacyRollbackPrepareCommand,
type LocalServiceManagerLegacyRollbackPrepareResult,
} from './service-manager/serviceLegacyRollback';
export async function prepareLocalDeployment(
input: unknown,
@@ -10,6 +10,7 @@ import {
inspectLocalDeploymentStatusCommandFile,
preflightLocalDeploymentComposeCommandFile,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollbackCommandFile,
prepareLocalDeploymentCommandFile,
restoreLocalDeploymentComposeCommitCommandFile,
restoreLocalDeploymentComposePrepareCommandFile,
@@ -22,7 +23,7 @@ import {
} from './localDeployment';
const USAGE =
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|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|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -36,6 +37,7 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'service-intent-prepare' &&
argv[0] !== 'service-outcome-consume' &&
argv[0] !== 'service-cutover-consume' &&
argv[0] !== 'service-legacy-rollback-prepare' &&
argv[0] !== 'cutover-legacy-stop' &&
argv[0] !== 'cutover-target-start' &&
argv[0] !== 'cutover-target-restart' &&
@@ -74,6 +76,8 @@ async function main(argv: readonly string[]): Promise<void> {
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-cutover-consume'
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-legacy-rollback-prepare'
? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
: argv[0] === 'cutover-legacy-stop'
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
: argv[0] === 'cutover-target-start' ||
@@ -0,0 +1,633 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
readPrivateLocalCommandFile,
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
import {
readTargetDataReconciliationEvidenceForPaths,
verifyTargetDataReconciliationEvidence,
type TargetDataReconciliationEvidence,
} from '../cutover/targetDataEvidence';
import { cutoverDigest } from '../cutover/targetEvidence';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
import {
localServiceManagerIntentPath,
normalizeLocalServiceManagerIntent,
} from './serviceBridgeContract';
import { normalizeLocalServiceManagerCutoverRecord } from './serviceCutoverJournal';
const SCHEMA = 'qinglong3-local-service-manager-rollback-preparation';
const ZERO_DIGEST = '0'.repeat(64);
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
const MAX_PATH_BYTES = 4_096;
const MAX_GENERATION = 15;
export interface LocalServiceManagerLegacyRollbackPrepareCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<{
cutoverId: string;
profile: 'edge' | 'standalone';
instanceId: string;
generation: number;
expectedActivationDigest: string;
expectedStoppedRecordDigest: string;
expectedInstanceHeadDigest: string;
requestedAtMs: number;
}>;
}
export interface LocalServiceManagerLegacyRollbackPrepareResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare';
readonly status: 'prepared' | 'existing' | 'not-prepared';
readonly state: 'rollback_prepared' | 'target_stopped';
readonly rollbackDisposition: TargetDataReconciliationEvidence['disposition'];
readonly cutoverId: string;
readonly generation: number;
readonly preparationDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalServiceManagerLegacyRollbackPreparation {
readonly schema: typeof SCHEMA;
readonly schemaVersion: 1;
readonly state: 'rollback_prepared';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly expectedInstanceHeadDigest: string;
readonly stoppedRecordDigest: string;
readonly stoppedManagerOutcomeDigest: string;
readonly applicationConfigDigest: string;
readonly commitmentDigest: string;
readonly shutdownReceiptDigest: string;
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
readonly requestedAtMs: number;
readonly preparationDigest: string;
}
interface AdoptedPaths {
readonly activationPath: string;
readonly legacySourcePath: string;
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
readonly commitmentDigest: string;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function safeAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${label} must be a supervisor-safe absolute path`);
}
return value;
}
function normalizeCommand(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackPrepareCommand> {
const command = object(value, 'service manager rollback prepare command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(options, ['allowRootService', 'deploymentRoot'], 'options');
const request = object(command.request, 'request');
exact(
request,
[
'cutoverId',
'expectedActivationDigest',
'expectedInstanceHeadDigest',
'expectedStoppedRecordDigest',
'generation',
'instanceId',
'profile',
'requestedAtMs',
],
'request',
);
const identity = currentIdentity();
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.service-manager.legacy-rollback.prepare' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
typeof request.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
typeof request.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
!Number.isSafeInteger(request.generation) ||
(request.generation as number) < 1 ||
(request.generation as number) > MAX_GENERATION ||
typeof request.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
typeof request.expectedStoppedRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedStoppedRecordDigest) ||
typeof request.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
!Number.isSafeInteger(request.requestedAtMs) ||
(request.requestedAtMs as number) < 0
) {
configurationError('service manager rollback prepare command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.service-manager.legacy-rollback.prepare' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService,
}),
request: Object.freeze({
cutoverId: request.cutoverId,
profile: request.profile,
instanceId: request.instanceId,
generation: request.generation as number,
expectedActivationDigest: request.expectedActivationDigest,
expectedStoppedRecordDigest: request.expectedStoppedRecordDigest,
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
requestedAtMs: request.requestedAtMs as number,
}),
});
}
function stoppedRecordPath(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
): string {
return path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
`service-manager-g${String(command.request.generation).padStart(
2,
'0',
)}-stopped.json`,
);
}
export function localServiceManagerLegacyRollbackPreparationPath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${String(generation).padStart(
2,
'0',
)}-rollback-prepared.json`,
);
}
function applicationSha256(filePath: string): string {
try {
return crypto
.createHash('sha256')
.update(fs.readFileSync(filePath))
.digest('hex');
} catch (error) {
return configurationError(
'application configuration cannot be hashed',
error,
);
}
}
function adoptedPaths(
applicationPath: string,
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
expectedApplicationDigest: string,
expectedCommitmentDigest: string,
): Readonly<AdoptedPaths> {
if (applicationSha256(applicationPath) !== expectedApplicationDigest) {
configurationError('adopted application configuration digest drifted');
}
const application = object(
readPrivateLocalCommandFile(applicationPath),
'adopted application configuration',
);
const storage = object(application.storage, 'adopted storage');
const cutover = object(application.cutover, 'adopted cutover');
const commitmentPath = safeAbsolutePath(
cutover.commitmentPath,
'commitmentPath',
);
const expectedCommitmentPath = path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
'0002-legacy-stopped.json',
);
const commitment = object(
readPrivateLocalCommandFile(commitmentPath),
'legacy silence commitment',
);
const { commitmentDigest, ...commitmentPayload } = commitment;
if (
application.schema !== 'qinglong/local-application-process@v3' ||
application.profile !== command.request.profile ||
application.instanceId !== command.request.instanceId ||
storage.mode !== 'adopted' ||
storage.expectedActivationDigest !==
command.request.expectedActivationDigest ||
cutover.cutoverId !== command.request.cutoverId ||
cutover.expectedCommitmentDigest !== expectedCommitmentDigest ||
commitmentPath !== expectedCommitmentPath ||
commitment.schemaVersion !== 1 ||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
commitment.state !== 'legacy_stopped' ||
commitment.cutoverId !== command.request.cutoverId ||
commitment.profile !== command.request.profile ||
commitment.instanceId !== command.request.instanceId ||
commitment.activationDigest !== command.request.expectedActivationDigest ||
commitmentDigest !== expectedCommitmentDigest ||
cutoverDigest(commitmentPayload) !== commitmentDigest
) {
configurationError('adopted application rollback binding drifted');
}
return Object.freeze({
activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'),
legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'),
targetDatabasePath: safeAbsolutePath(
storage.targetPath,
'targetDatabasePath',
),
expectedActivationDigest: command.request.expectedActivationDigest,
commitmentDigest: expectedCommitmentDigest,
});
}
function preparationRecord(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
stopped: ReturnType<typeof normalizeLocalServiceManagerCutoverRecord>,
reconciliation: Readonly<TargetDataReconciliationEvidence>,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
if (
stopped.evidence.shutdownReceiptDigest === null ||
stopped.evidence.manualReason !== null
) {
configurationError('stopped service evidence cannot authorize rollback');
}
const payload = Object.freeze({
schema: SCHEMA,
schemaVersion: 1 as const,
state: 'rollback_prepared' as const,
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
generation: command.request.generation,
activationDigest: command.request.expectedActivationDigest,
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
stoppedRecordDigest: stopped.recordDigest,
stoppedManagerOutcomeDigest: stopped.evidence.managerOutcomeDigest,
applicationConfigDigest: stopped.evidence.applicationConfigDigest,
commitmentDigest: stopped.evidence.commitmentDigest,
shutdownReceiptDigest: stopped.evidence.shutdownReceiptDigest,
reconciliation,
requestedAtMs: command.request.requestedAtMs,
});
return Object.freeze({
...payload,
preparationDigest: cutoverDigest(payload),
});
}
export function normalizeLocalServiceManagerLegacyRollbackPreparation(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
const record = object(value, 'service manager rollback preparation');
exact(
record,
[
'activationDigest',
'applicationConfigDigest',
'commitmentDigest',
'cutoverId',
'expectedInstanceHeadDigest',
'generation',
'instanceId',
'preparationDigest',
'profile',
'reconciliation',
'requestedAtMs',
'schema',
'schemaVersion',
'shutdownReceiptDigest',
'state',
'stoppedManagerOutcomeDigest',
'stoppedRecordDigest',
],
'service manager rollback preparation',
);
const reconciliation = verifyTargetDataReconciliationEvidence(
record.reconciliation,
);
const { preparationDigest, ...rawPayload } = record;
const payload = { ...rawPayload, reconciliation };
if (
record.schema !== SCHEMA ||
record.schemaVersion !== 1 ||
record.state !== 'rollback_prepared' ||
typeof record.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(record.cutoverId) ||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
typeof record.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(record.instanceId) ||
!Number.isSafeInteger(record.generation) ||
(record.generation as number) < 1 ||
(record.generation as number) > MAX_GENERATION ||
typeof record.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.activationDigest) ||
typeof record.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
typeof record.stoppedRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(record.stoppedRecordDigest) ||
typeof record.stoppedManagerOutcomeDigest !== 'string' ||
!DIGEST_PATTERN.test(record.stoppedManagerOutcomeDigest) ||
typeof record.applicationConfigDigest !== 'string' ||
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
typeof record.commitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(record.commitmentDigest) ||
typeof record.shutdownReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(record.shutdownReceiptDigest) ||
reconciliation.disposition !== 'rollback_candidate' ||
!Number.isSafeInteger(record.requestedAtMs) ||
(record.requestedAtMs as number) < 0 ||
typeof preparationDigest !== 'string' ||
!DIGEST_PATTERN.test(preparationDigest) ||
cutoverDigest(payload) !== preparationDigest
) {
configurationError('service manager rollback preparation drifted');
}
return Object.freeze({
...(payload as Omit<
LocalServiceManagerLegacyRollbackPreparation,
'preparationDigest'
>),
preparationDigest,
});
}
function result(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
status: LocalServiceManagerLegacyRollbackPrepareResult['status'],
state: LocalServiceManagerLegacyRollbackPrepareResult['state'],
reconciliation: Readonly<TargetDataReconciliationEvidence>,
preparationDigest: string,
instanceHeadDigest: string,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state,
rollbackDisposition: reconciliation.disposition,
cutoverId: command.request.cutoverId,
generation: command.request.generation,
preparationDigest,
instanceHeadDigest,
});
}
export function prepareLocalServiceManagerLegacyRollback(
input: unknown,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
const command = normalizeCommand(input);
const identity = currentIdentity();
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
command.request.instanceId,
identity.uid,
);
if (
head.profile !== command.request.profile ||
head.cutoverId !== command.request.cutoverId ||
head.activationDigest !== command.request.expectedActivationDigest ||
head.generation !== command.request.generation ||
(head.state !== 'target_stopped' && head.state !== 'rollback_prepared')
) {
configurationError('rollback prepare is not bound to the instance head');
}
const stopped = normalizeLocalServiceManagerCutoverRecord(
readPrivateLocalCommandFile(stoppedRecordPath(command)),
);
if (
stopped.state !== 'target_stopped' ||
stopped.action !== 'stop' ||
stopped.cutoverId !== command.request.cutoverId ||
stopped.profile !== command.request.profile ||
stopped.instanceId !== command.request.instanceId ||
stopped.activationDigest !== command.request.expectedActivationDigest ||
stopped.generation !== command.request.generation ||
stopped.recordDigest !== command.request.expectedStoppedRecordDigest ||
stopped.completedAtMs > command.request.requestedAtMs ||
(head.state === 'target_stopped' &&
head.sourceRecordDigest !== stopped.recordDigest) ||
(head.state === 'target_stopped' &&
head.headDigest !== command.request.expectedInstanceHeadDigest)
) {
configurationError('stopped service rollback binding drifted');
}
const intent = normalizeLocalServiceManagerIntent(
readPrivateLocalJsonFile(
localServiceManagerIntentPath(
command.options.deploymentRoot,
stopped.actionId,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
if (
!UUID_V4_PATTERN.test(intent.actionId) ||
intent.intentDigest !== stopped.intentDigest ||
intent.action !== 'stop' ||
intent.profile !== command.request.profile ||
intent.instanceId !== command.request.instanceId ||
intent.lineage.mode !== 'adopted' ||
intent.lineage.cutoverId !== command.request.cutoverId ||
intent.lineage.generation !== command.request.generation ||
intent.lineage.expectedActivationDigest !==
command.request.expectedActivationDigest
) {
configurationError('stopped service intent binding drifted');
}
const paths = adoptedPaths(
intent.deployment.applicationConfigPath,
command,
stopped.evidence.applicationConfigDigest,
stopped.evidence.commitmentDigest,
);
const reconciliation = readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: paths.activationPath,
legacySourcePath: paths.legacySourcePath,
targetDatabasePath: paths.targetDatabasePath,
expectedActivationDigest: paths.expectedActivationDigest,
},
identity.uid,
);
if (reconciliation.disposition !== 'rollback_candidate') {
if (head.state !== 'target_stopped') {
configurationError('prepared rollback data drifted after authorization');
}
return result(
command,
'not-prepared',
'target_stopped',
reconciliation,
ZERO_DIGEST,
head.headDigest,
);
}
const preparation = preparationRecord(command, stopped, reconciliation);
const filePath = localServiceManagerLegacyRollbackPreparationPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
);
if (head.state === 'rollback_prepared') {
const existing = normalizeLocalServiceManagerLegacyRollbackPreparation(
readPrivateLocalCommandFile(filePath),
);
if (
existing.preparationDigest !== preparation.preparationDigest ||
head.sourceRecordDigest !== existing.preparationDigest ||
head.previousHeadDigest !== command.request.expectedInstanceHeadDigest
) {
configurationError('service manager rollback preparation replay drifted');
}
return result(
command,
'existing',
'rollback_prepared',
reconciliation,
existing.preparationDigest,
head.headDigest,
);
}
const contents = `${JSON.stringify(preparation, null, 2)}\n`;
preflightPublishedFile(
filePath,
contents,
0o600,
identity.uid,
'service manager rollback preparation',
);
const status = publishExactFile(
filePath,
contents,
0o600,
identity.uid,
'service manager rollback preparation',
);
const next = advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: command.options.deploymentRoot },
request: {
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
expectedActivationDigest: command.request.expectedActivationDigest,
requestedAtMs: command.request.requestedAtMs,
},
},
identity.uid,
'rollback_prepared',
command.request.generation,
preparation.preparationDigest,
);
return result(
command,
status,
'rollback_prepared',
reconciliation,
preparation.preparationDigest,
next.headDigest,
);
}
export function prepareLocalServiceManagerLegacyRollbackCommandFile(
filePath: string,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
return prepareLocalServiceManagerLegacyRollback(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
@@ -8,6 +8,10 @@ const { test } = require('node:test');
const {
consumeLocalServiceManagerCutoverOutcome,
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
const {
localServiceManagerLegacyRollbackPreparationPath,
prepareLocalServiceManagerLegacyRollback,
} = require('../dist/deployment/service-manager/serviceLegacyRollback.js');
const {
prepareLocalServiceManagerIntent,
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
@@ -163,8 +167,10 @@ function fixture(t) {
cutoverId,
activationDigest,
commitmentDigest,
commitmentPath,
applicationPath,
sourcePath,
targetPath,
identity,
procRoot,
};
@@ -315,6 +321,69 @@ function consumeCommand(state, prepared) {
};
}
async function stopAdoptedTarget(state, suffix = '031') {
const active = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
`123e4567-e89b-42d3-a456-426614174${suffix}`,
);
publishOutcome(active, 'install-enable-start', 'active', 4723, 1786416000200);
const startupReceiptDigest = publishReceipt(state, 4723, '100007');
const activeResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, active),
{ procRoot: state.procRoot },
);
const stopped = prepare(
state,
1,
'stop',
activeResult.recordDigest,
`123e4567-e89b-42d3-a456-426614174${String(Number(suffix) + 1).padStart(
3,
'0',
)}`,
);
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
publishShutdownReceipt(state, 4723, '100007', startupReceiptDigest);
fs.rmSync(path.join(state.procRoot, '4723'), {
recursive: true,
force: true,
});
const stoppedResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, stopped),
{ procRoot: state.procRoot },
);
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
return { stoppedResult, head };
}
function rollbackPrepareCommand(state, stoppedResult, head) {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.prepare',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
},
request: {
cutoverId: state.cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
expectedActivationDigest: state.activationDigest,
expectedStoppedRecordDigest: stoppedResult.recordDigest,
expectedInstanceHeadDigest: head.headDigest,
requestedAtMs: 1786416000400,
},
};
}
test('commits adopted service active evidence and replays from the instance head', async (t) => {
const state = fixture(t);
const prepared = prepare(
@@ -468,6 +537,117 @@ test('stop advances only after the exact receipted process identity disappears',
assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest);
});
test('prepares and exactly replays lossless service-manager legacy rollback evidence', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state);
const command = rollbackPrepareCommand(state, stoppedResult, head);
const prepared = prepareLocalServiceManagerLegacyRollback(command);
assert.equal(prepared.status, 'prepared');
assert.equal(prepared.state, 'rollback_prepared');
assert.equal(prepared.rollbackDisposition, 'rollback_candidate');
assert.match(prepared.preparationDigest, /^[0-9a-f]{64}$/);
const preparedHead = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(preparedHead.state, 'rollback_prepared');
assert.equal(preparedHead.sourceRecordDigest, prepared.preparationDigest);
const record = JSON.parse(
fs.readFileSync(
localServiceManagerLegacyRollbackPreparationPath(
state.root,
state.cutoverId,
1,
),
'utf8',
),
);
assert.equal(record.stoppedRecordDigest, stoppedResult.recordDigest);
assert.equal(record.reconciliation.targetMatchesActivation, true);
assert.equal(record.reconciliation.sourceMatchesRecovery, true);
assert.equal(record.reconciliation.targetSidecarsClear, true);
assert.equal(record.reconciliation.sourceSidecarsClear, true);
const replay = prepareLocalServiceManagerLegacyRollback(command);
assert.equal(replay.status, 'existing');
assert.equal(replay.preparationDigest, prepared.preparationDigest);
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
});
test('keeps target_stopped when service-manager rollback would discard target writes', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '033');
fs.writeFileSync(state.targetPath, 'target-written-by-qinglong3\n', {
mode: 0o600,
});
const result = prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
);
assert.equal(result.status, 'not-prepared');
assert.equal(result.state, 'target_stopped');
assert.equal(result.rollbackDisposition, 'reconciliation_required');
assert.equal(result.preparationDigest, '0'.repeat(64));
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
assert.equal(
fs.existsSync(
localServiceManagerLegacyRollbackPreparationPath(
state.root,
state.cutoverId,
1,
),
),
false,
);
});
test('rejects application configuration drift before service-manager rollback preparation', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '035');
fs.appendFileSync(state.applicationPath, ' ');
assert.throws(
() =>
prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
),
/application configuration digest drifted/,
);
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
});
test('rejects legacy silence commitment drift before service-manager rollback preparation', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '037');
const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8'));
commitment.observedAtMs += 1;
writePrivate(state.commitmentPath, commitment);
assert.throws(
() =>
prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
),
/application rollback binding drifted/,
);
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
});
test('stop without an exact shutdown receipt requires manual resolution', async (t) => {
const state = fixture(t);
const first = prepare(
@@ -81,6 +81,9 @@ function actorReport(name, kind, identityMode) {
'serviceProcessIdentity',
'adoptedCutoverActive',
'adoptedCutoverStopped',
'adoptedRollbackPrepared',
'adoptedRollbackReplay',
'adoptedRollbackCandidate',
];
if (
report.managerKind !== kind ||
@@ -192,6 +195,15 @@ function main() {
adoptedCutoverStopped: reports.every(
(report) => report.gates.adoptedCutoverStopped,
),
adoptedRollbackPrepared: reports.every(
(report) => report.gates.adoptedRollbackPrepared,
),
adoptedRollbackReplay: reports.every(
(report) => report.gates.adoptedRollbackReplay,
),
adoptedRollbackCandidate: reports.every(
(report) => report.gates.adoptedRollbackCandidate,
),
systemdRestart: reports.some(
(report) =>
report.managerKind === 'systemd' && report.gates.restarted,
@@ -368,7 +368,12 @@ function adoptedFixture(root, kind, uid, gid) {
uid,
gid,
);
return { cutoverId, activationDigest, commitmentDigest };
return {
cutoverId,
instanceId: commitmentPayload.instanceId,
activationDigest,
commitmentDigest,
};
}
async function consumeCutover(root, prepared, uid, gid) {
@@ -401,6 +406,36 @@ async function consumeCutover(root, prepared, uid, gid) {
return ownerCli(uid, gid, 'service-cutover-consume', commandPath);
}
function prepareAdoptedRollback(root, adopted, stopped, uid, gid) {
const command = {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.prepare',
options: { deploymentRoot: root, allowRootService: uid === 0 },
request: {
cutoverId: adopted.cutoverId,
profile: 'edge',
instanceId: adopted.instanceId,
generation: 1,
expectedActivationDigest: adopted.activationDigest,
expectedStoppedRecordDigest: stopped.recordDigest,
expectedInstanceHeadDigest: stopped.instanceHeadDigest,
requestedAtMs: Date.now(),
},
};
const commandPath = path.join(root, 'owner-legacy-rollback-prepare.json');
writePrivate(
commandPath,
`${JSON.stringify(command, null, 2)}\n`,
0o600,
uid,
gid,
);
return {
commandPath,
result: ownerCli(uid, gid, 'service-legacy-rollback-prepare', commandPath),
};
}
function execute(root, controllerRoot, managerOptions, prepared, uid, gid) {
const command = {
schemaVersion: 1,
@@ -670,6 +705,29 @@ async function main(argv) {
) {
fail('adopted service did not commit target_stopped');
}
const adoptedRollback = prepareAdoptedRollback(
root,
adopted,
adoptedStopped,
uid,
gid,
);
const adoptedRollbackReplay = ownerCli(
uid,
gid,
'service-legacy-rollback-prepare',
adoptedRollback.commandPath,
);
if (
adoptedRollback.result.status !== 'prepared' ||
adoptedRollback.result.state !== 'rollback_prepared' ||
adoptedRollback.result.rollbackDisposition !== 'rollback_candidate' ||
adoptedRollbackReplay.status !== 'existing' ||
adoptedRollbackReplay.preparationDigest !==
adoptedRollback.result.preparationDigest
) {
fail('adopted rollback preparation did not converge exactly');
}
const payload = {
schemaVersion: 1,
evidenceClass: 'qinglong3_service_manager_bridge_live_actor',
@@ -686,6 +744,8 @@ async function main(argv) {
adoptedCutover: {
active: adoptedActive,
stopped: adoptedStopped,
rollbackPrepared: adoptedRollback.result,
rollbackReplay: adoptedRollbackReplay,
},
gates: {
rootCommandFile: true,
@@ -699,6 +759,9 @@ async function main(argv) {
serviceProcessIdentity: true,
adoptedCutoverActive: true,
adoptedCutoverStopped: true,
adoptedRollbackPrepared: true,
adoptedRollbackReplay: true,
adoptedRollbackCandidate: true,
},
};
process.stdout.write(
+2 -2
View File
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
},
{
sourceFiles: 107,
sourceFiles: 108,
rootSourceFiles: 1,
rootSourceLines: 50,
nestedSourceFiles: 106,
nestedSourceFiles: 107,
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
},
);