-
Notifications
You must be signed in to change notification settings - Fork 1
Recover leader preferences with retained rule transactions #1291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,7 +64,7 @@ | |
| // MARK: - Apply | ||
|
|
||
| public func applyConfiguration(dryRun: Bool = false) async throws -> CLIApplyResult { | ||
| let service: ConfigurationService = if let operation { operation.service } | ||
| else { await MainActor.run { ConfigurationService(configDirectory: configDirectory) } } | ||
| return try await service.operationGate.withOperation(using: operation?.permit) { permit in | ||
| try await applyConfiguration(dryRun: dryRun, service: service, permit: permit) | ||
|
|
@@ -74,6 +74,17 @@ | |
| private func applyConfiguration( | ||
| dryRun: Bool, service: ConfigurationService, permit: ConfigurationOperationGate.Permit | ||
| ) async throws -> CLIApplyResult { | ||
| try await service.recoverPendingRuleWrite( | ||
| mutationPermit: permit, | ||
| preferenceDefaults: PreferencesService.canonicalDefaults | ||
| ) | ||
| _ = try await service.applyRecoveredRuntimeIfNeeded( | ||
| mutationPermit: permit, | ||
| reloadHandler: { await reloadResult() } | ||
| ) | ||
| await MainActor.run { | ||
| PreferencesService.shared.reloadLeaderKeyPreference() | ||
| } | ||
| let collections = await ruleCollectionLoader() | ||
| let customRules = await customRuleLoader() | ||
| let enabledCount = collections.filter(\.isEnabled).count | ||
|
|
@@ -85,32 +96,22 @@ | |
| // neither in sync with the collection, so `keypath collection`/JSON edits to | ||
| // `selectedOutput` were silently ignored by `keypath apply`. Reconcile both, mirroring | ||
| // the in-process reconcile the app does on load (RuleCollectionsManager). See #889. | ||
| let reconcile = await applyReconciledLeaderKeyPreference(from: collections) | ||
| let reconcile = await reconciledLeaderKeyPreference(from: collections) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the CLI previously crashed after staging a leader/config revision but before committing its raw journal, this reads the staged preference before recovery. When the collection already matches that value, Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in da11ea7. ConfigFacade now calls retained-journal recovery with canonical defaults immediately after gate admission, reloads the shared preference cache, and only then loads sources and derives the leader candidate. Added a deterministic interrupted raw-journal apply test that verifies the recovered revision is reconciled and recommitted, the journal is removed, and the final config/preference both use tab. |
||
| let reconciledCollections = reconcile.map { r in | ||
| LeaderKeyPreference.reconcileLeaderActivators( | ||
| in: collections, | ||
| key: r.reconciled.key, | ||
| targetLayer: r.reconciled.targetLayer | ||
| ) | ||
| } ?? collections | ||
| let leaderSnapshot = reconcile?.previous | ||
|
|
||
| if dryRun { | ||
| // A dry run must not persist the reconciled preference. Generation reads the live | ||
| // shared preference, so we generate/validate against the reconciled value and then | ||
| // always restore the original — including when generation/validation throws | ||
| // (e.g. a mapping conflict) so a failed preview never leaves the store mutated. | ||
| do { | ||
| let previewConfig = try await service.generateConfiguration( | ||
| ruleCollections: reconciledCollections, | ||
| customRules: customRules | ||
| ) | ||
| try await validateDryRunConfig(previewConfig.content) | ||
| } catch { | ||
| await restoreLeaderKeyPreference(leaderSnapshot) | ||
| throw error | ||
| } | ||
| await restoreLeaderKeyPreference(leaderSnapshot) | ||
| let previewConfig = try await service.generateConfiguration( | ||
| ruleCollections: reconciledCollections, | ||
| customRules: customRules, | ||
| leaderKeyPreference: reconcile?.reconciled | ||
| ) | ||
| try await validateDryRunConfig(previewConfig.content) | ||
|
|
||
| return CLIApplyResult( | ||
| collectionsCount: collections.count, | ||
|
|
@@ -122,18 +123,32 @@ | |
| ) | ||
| } | ||
|
|
||
| try await service.saveConfiguration( | ||
| ruleCollections: reconciledCollections, | ||
| customRules: customRules, | ||
| mutationPermit: permit | ||
| ) | ||
|
|
||
| let reloadSuccess = if let reloadHandler { | ||
| await reloadHandler() | ||
| if let reconcile { | ||
| let preferenceChange = try RecoverableRuleWrite.PreferenceChange.leader( | ||
| before: reconcile.previousData, | ||
| after: JSONEncoder().encode(reconcile.reconciled) | ||
| ) | ||
| try await service.saveConfiguration( | ||
| ruleCollections: reconciledCollections, | ||
| customRules: customRules, | ||
| leaderKeyPreference: reconcile.reconciled, | ||
| preferenceDefaults: PreferencesService.canonicalDefaults, | ||
| preferenceChanges: [preferenceChange], | ||
| mutationPermit: permit | ||
| ) | ||
| await MainActor.run { | ||
| PreferencesService.shared.reloadLeaderKeyPreference() | ||
| } | ||
| } else { | ||
| await tcpReload() | ||
| try await service.saveConfiguration( | ||
| ruleCollections: reconciledCollections, | ||
| customRules: customRules, | ||
| mutationPermit: permit | ||
| ) | ||
| } | ||
|
|
||
| let reloadSuccess = await reloadRuntime() | ||
|
|
||
| return CLIApplyResult( | ||
| collectionsCount: collections.count, | ||
| enabledCount: enabledCount, | ||
|
|
@@ -143,35 +158,46 @@ | |
| ) | ||
| } | ||
|
|
||
| private func reloadResult() async -> ReloadResult { | ||
| let success = await reloadRuntime() | ||
| return ReloadResult( | ||
| success: success, response: nil, | ||
| errorMessage: success ? nil : "CLI reload failed", | ||
| protocol: nil, disposition: success ? .applied : .failed | ||
| ) | ||
| } | ||
|
|
||
| private func reloadRuntime() async -> Bool { | ||
| if let reloadHandler { return await reloadHandler() } | ||
| return await tcpReload() | ||
| } | ||
|
|
||
| private struct LeaderReconcile { | ||
| let reconciled: LeaderKeyPreference | ||
| let previous: LeaderKeyPreference | ||
| let previousData: Data? | ||
| } | ||
|
|
||
| /// Sync `PreferencesService.leaderKeyPreference` from the enabled Leader Key collection's | ||
| /// explicit `selectedOutput` (see `LeaderKeyPreference.reconciled`), returning both the | ||
| /// reconciled value (so the caller can rewrite the matching leader activators without | ||
| /// re-reading shared state) and the pre-reconcile `previous` (so a dry run can restore it). | ||
| /// On a real apply the caller keeps the reconciled value in place so it drives the generated | ||
| /// config and future reads, matching what the app does on load. Returns `nil` when nothing | ||
| /// changed. | ||
| /// Prepare a leader preference candidate without changing shared defaults. A real apply | ||
| /// journals this exact preimage with the generated config; a dry run uses only the candidate. | ||
| @MainActor | ||
| private func applyReconciledLeaderKeyPreference(from collections: [RuleCollection]) -> LeaderReconcile? { | ||
| let current = PreferencesService.shared.leaderKeyPreference | ||
| private func reconciledLeaderKeyPreference(from collections: [RuleCollection]) -> LeaderReconcile? { | ||
| let defaults = PreferencesService.canonicalDefaults | ||
| let previousData = defaults.data(forKey: PreferencesService.leaderKeyPreferenceKey) | ||
| let current: LeaderKeyPreference = if let previousData { | ||
| (try? JSONDecoder().decode(LeaderKeyPreference.self, from: previousData)) ?? .default | ||
| } else { | ||
| .default | ||
| } | ||
| guard let reconciled = LeaderKeyPreference.reconciled(from: collections, current: current) else { | ||
| return nil | ||
| } | ||
| AppLogger.shared.log( | ||
| "🔑 [ConfigFacade] Reconciling leader key from collection selectedOutput: '\(reconciled.key)' (was '\(current.key)')" | ||
| ) | ||
| PreferencesService.shared.leaderKeyPreference = reconciled | ||
| return LeaderReconcile(reconciled: reconciled, previous: current) | ||
| } | ||
|
|
||
| @MainActor | ||
| private func restoreLeaderKeyPreference(_ snapshot: LeaderKeyPreference?) { | ||
| guard let snapshot else { return } | ||
| PreferencesService.shared.leaderKeyPreference = snapshot | ||
| return LeaderReconcile( | ||
| reconciled: reconciled, | ||
| previousData: previousData | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - Backup / Restore | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a previous app save crashed after Kanata accepted the staged rules but before the journal was committed, this call restores the files/preferences and sets
needsRecoveredRuntimeRefresh, but the CLI never callsapplyRecoveredRuntimeIfNeeded. A dry run returns without reloading, and generation or validation can also throw before the final reload, leaving Kanata on the attempted revision after the journal has been removed; because the marker is only in this service instance, it is lost when the CLI exits. Apply the recovered runtime before proceeding, or retain durable recovery evidence until it is reloaded.AGENTS.md reference: AGENTS.md:L344-L344
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 547a231. Immediately after retained-journal recovery, ConfigFacade now calls
applyRecoveredRuntimeIfNeededthrough the same injected/TCP reload path before refreshing preferences, loading sources, or generating/validating a new candidate. A failed recovery reload aborts while the service still owns the recovery requirement. The interrupted CLI test now asserts exactly two reloads: restored revision first, newly committed apply second. ConfigFacadeTests 12/12 and ConfigFacadeAdmissionTests 4/4 pass.