refactor(runtime): route catalog sync through the convergence spine

- catalogDidCommit 的 deployed 分支体(派生 + 幂等跳过 + deployPrepared)
  与 deploy() 逐语义等价,改走 deploy();runtimeAlreadyConverged 独立机制
  删除,plan 空判定由 apply 层的 convergencePlanIsEmpty 统一承担。
- deploy/deployPrepared 返回 ConvergenceResult:被取代的派生按 .superseded
  呈现为 .revalidated,其余路径仍由状态面映射。
- reexpand 三-case 开关收敛为 convergeToReexpanded:convergeAgent /
  updateSettings / resyncOnLaunch 各变一行;catalogDidCommit 保留按 case
  的结构化 outcome 映射。
- 统一设置持久化 epilogue 为 persistSettings:setAgentEnabled /
  setSystemProxyEnabled / transitionMode 三处相同 save-fail 语义折叠
  (updateSettings 的 throw 变体与级联关闭的 return-nil 变体语义不同,
  保持原样)。
- SkeletonTests 运行时适配器豁免名单登记 +Convergence.swift。
This commit is contained in:
Qiu Yuzhou
2026-10-03 11:08:24 +08:00
parent 8587b2e80c
commit d7169d9f01
5 changed files with 54 additions and 92 deletions
@@ -57,13 +57,7 @@ extension ProxyRuntimeController {
}
var next = settings
next.agentEnabled = enabled
do {
try settingsStore.save(next)
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.persistence)
return
}
guard persistSettings(next) else { return }
settings = next
if enabled {
await convergeAgent()
@@ -103,13 +97,7 @@ extension ProxyRuntimeController {
guard enabled != settings.systemProxyEnabled else { return }
var next = settings
next.systemProxyEnabled = enabled
do {
try settingsStore.save(next)
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.persistence)
return
}
guard persistSettings(next) else { return }
settings = next
if enabled {
systemProxyInitialApplyPending = true
@@ -139,14 +127,7 @@ extension ProxyRuntimeController {
return
}
let catalog = catalogSnapshotReader.catalogSnapshot
switch reexpand(in: catalog) {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
await deployListeningWithoutTarget()
}
await convergeToReexpanded(catalog)
systemProxyStartupInProgress = false
if settings.systemProxyEnabled {
lastDesiredSystemProxyConfiguration = desiredSystemProxyConfiguration
@@ -162,15 +143,7 @@ extension ProxyRuntimeController {
/// Agent 意图开启的收敛:注册态不是事实来源,意图才是——未注册也会注册,
/// 已注册则重校验目标并部署。偏好重置后也走此路径,把运行时收敛到出厂设置。
func convergeAgent() async {
let catalog = catalogSnapshotReader.catalogSnapshot
switch reexpand(in: catalog) {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
await deployListeningWithoutTarget()
}
await convergeToReexpanded(catalogSnapshotReader.catalogSnapshot)
}
/// Agent 意图关闭的收敛(issue #71):只停止本地监听,不做系统代理清理。
@@ -203,38 +176,41 @@ extension ProxyRuntimeController {
await deploy(document)
}
/// 把「运行定义档」意图推到运行时:计划动作 → 顺序执行 → 端点健康呈现。
/// 把「运行定义档」意图推到运行时:派生文档 → 收敛脊柱 apply(幂等跳过、
/// 计划执行、健康门)。
@discardableResult
func deploy(_ sourceDocument: SslocalRuntimeDocument) async -> Bool {
func deploy(_ sourceDocument: SslocalRuntimeDocument) async -> ConvergenceResult {
if listenSettingsUnreadable || settingsUnreadable {
await refuseDeployForUnreadableListenSettings()
return false
return .failed
}
let contract: PreparedRuntimeContract
do {
contract = try PreparedRuntimeContract(await runtimeDocument(sourceDocument, for: proxyMode))
} catch RulePreparationError.superseded {
return false
return .superseded
} catch {
// 规则快照缺失/损坏:不静默退化成全局(issue #63 AC4)。
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.runtimeFile)
return false
return .failed
}
return await deployPrepared(contract, preparation: runtimePreparationGeneration)
}
func deployPrepared(_ contract: PreparedRuntimeContract, preparation: Int) async -> Bool {
guard preparation == runtimePreparationGeneration else { return false }
@discardableResult
func deployPrepared(
_ contract: PreparedRuntimeContract, preparation: Int
) async -> ConvergenceResult {
guard preparation == runtimePreparationGeneration else { return .superseded }
let document = contract.document
var ticket = convergenceTicket()
let result = await apply(
return await apply(
document, ticket: &ticket, checking: [.preparation],
requiresReceipt: document.aclRuntime != nil,
convergeProxyOnSuccess: true,
preserveProxyOnFailure: false,
proxyTail: .none, rollback: nil)
return result != .failed
}
/// D8「任何路径不静默改端口」:监听设置不可读时以占位出厂端口部署等于
@@ -64,7 +64,8 @@ extension ProxyRuntimeController {
/// 派生文档对当前运行时是否无事可做(CONTEXT.md「有效值未变的提交不做
/// 运行时收敛」):磁盘契约与派生字节逐位相同、agent 已注册且 wrapper
/// 存活、控制器处于健康运行态——计划层动作序列为空即幂等跳过。判定在
/// 置 `starting` 之前进行,跳过路径不闪状态、不重走健康门。
/// 置 `starting` 之前进行,跳过路径不闪状态、不重走健康门;磁盘漂移或
/// wrapper 失踪时动作序列自然非空,走完整 deploy 自愈。
func convergencePlanIsEmpty(
_ document: SslocalRuntimeDocument, preparedContract: PreparedRuntimeContract?
) -> Bool {
@@ -82,6 +83,34 @@ extension ProxyRuntimeController {
return actions.isEmpty
}
/// 统一的设置持久化 epilogue:保存失败不静默偏离持久化事实——记录日志、
/// 呈现 `serviceFailed(.persistence)`,调用方放弃本次收敛。
func persistSettings(_ proposed: ProxySettings) -> Bool {
do {
try settingsStore.save(proposed)
return true
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.persistence)
return false
}
}
/// reexpand 三-case 开关的收敛主体(显式收敛、设置保存、启动重同步共用):
/// deployed → 部署派生文档;目标失效 → 清除并按意图收敛;无目标 → 空监听。
/// 幂等跳过由 apply 层的 plan 空判定承担;目录重校验(跳过名单、目标失效
/// 清除)已在 `reexpand` 完成,不受其影响。
func convergeToReexpanded(_ catalog: ConfigurationCatalog) async {
switch reexpand(in: catalog) {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
await deployListeningWithoutTarget()
}
}
/// 收敛脊柱 apply 层的结果。
enum ConvergenceResult {
/// 部署已执行且启动健康通过。
@@ -41,13 +41,7 @@ extension ProxyRuntimeController {
var next = settings
next.preferredMode = mode.kind
next.ruleDefaultAction = ruleDefaultAction
do {
try settingsStore.save(next)
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.persistence)
return
}
guard persistSettings(next) else { return }
settings = next
proxyMode = mode
modeChangeGeneration += 1
@@ -7,26 +7,17 @@ extension ProxyRuntimeController {
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。agent 意图开启时
/// 原子更新运行时;目标失效 → 清除目标但 agent 继续监听。返回结构化收敛
/// 结果,健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。
/// 「有效值未变的提交不做运行时收敛」由收敛脊柱 apply 层的 plan 空判定
/// 承担(`convergencePlanIsEmpty`);派生被更新的收敛取代时按重校验呈现。
func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome {
switch reexpand(in: catalog) {
case .deployed(let configuration):
guard settings.agentEnabled else {
return .revalidated
}
do {
let document = try await runtimeDocument(configuration.document, for: proxyMode)
let preparation = runtimePreparationGeneration
let contract = try PreparedRuntimeContract(document)
if runtimeAlreadyConverged(with: contract) {
RuntimeLog.emit(.contractUnchanged)
} else {
_ = await deployPrepared(contract, preparation: preparation)
}
} catch RulePreparationError.superseded {
let result = await deploy(configuration.document)
if case .superseded = result {
return .revalidated
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.runtimeFile)
}
return Self.syncOutcome(
agentState: state, systemProxyState: systemProxyState,
@@ -50,28 +41,6 @@ extension ProxyRuntimeController {
}
}
/// 目录提交去抖(CONTEXT.md「有效值未变的提交不做运行时收敛」):派生文档
/// (含模式/ACL 合并)与磁盘契约逐字节相等、agent 已注册且 wrapper 存活、
/// 控制器处于健康运行态时,本次提交对运行时无事可做——不写契约、不发信号、
/// 不闪 starting、不重走健康门与系统代理收敛。判定复用计划层语义
/// (`ProxyRuntimePlan.actions` 返回空 = 幂等跳过):磁盘漂移或 wrapper 失踪
/// 时动作序列自然非空,走完整 deploy 自愈。目录重校验(跳过名单、目标失效
/// 清除)已在 `reexpand` 完成,不受此门影响。
private func runtimeAlreadyConverged(with contract: PreparedRuntimeContract) -> Bool {
switch state {
case .running, .firewallBlocked:
break
case .off, .starting, .launchFailed, .requiresApproval, .serviceFailed:
return false
}
let actions = ProxyRuntimePlan.actions(
intent: .run(contract.document),
agentStatus: agent.status,
wrapper: wrapperState(),
contractOnDisk: runtimeFileStore.readData(), preparedContract: contract)
return actions.isEmpty
}
/// 部署后的控制器状态 → 结构化收敛结果:健康通过(含防火墙受阻的运行态)
/// 视为已收敛;系统代理写入失败携带点名细节,待应用不算目录收敛失败。
private static func syncOutcome(
@@ -103,14 +72,7 @@ extension ProxyRuntimeController {
listenSettingsUnreadable = false
settingsUnreadable = false
guard state != .off else { return }
switch reexpand(in: catalog) {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
await deployListeningWithoutTarget()
}
await convergeToReexpanded(catalog)
}
/// Applies the post-import 2.0 runtime boundary without touching
+2 -1
View File
@@ -61,9 +61,10 @@ final class CatalogWorkflowArchitectureTests: XCTestCase {
"MainApp.swift",
"CatalogCommitCoordinator.swift",
"ProxyRuntimeController.swift",
// 控制器按命令面/设置与目录同步/系统代理门禁/事实投影分文件(同属运行时适配器)。
// 控制器按命令面/设置与目录同步/收敛脊柱/系统代理门禁/事实投影分文件(同属运行时适配器)。
"ProxyRuntimeController+Commands.swift",
"ProxyRuntimeController+SettingsSync.swift",
"ProxyRuntimeController+Convergence.swift",
"ProxyRuntimeController+SystemProxyGate.swift",
"ProxyRuntimeController+Facts.swift",
"SystemProxyHelperService.swift",