Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 70 additions & 44 deletions Sources/KeyPathAppKit/CLI/ConfigFacade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
// MARK: - Apply

public func applyConfiguration(dryRun: Bool = false) async throws -> CLIApplyResult {
let service: ConfigurationService = if let operation { operation.service }

Check warning on line 67 in Sources/KeyPathAppKit/CLI/ConfigFacade.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
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)
Expand All @@ -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
)
Comment on lines +77 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the runtime after recovering the retained revision

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 calls applyRecoveredRuntimeIfNeeded. 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

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 applyRecoveredRuntimeIfNeeded through 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.

_ = 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
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover the raw journal before deriving the CLI candidate

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, reconcile is nil and the non-transactional saveConfiguration branch leaves the old journal in place, so a later startup recovery can undo this apparently successful apply; when it differs, the transactional overload recovers to the older preference and then rejects the now-stale preimage. Recover pending raw writes before loading the preference and deriving reconciliation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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,
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading