fix: 加字段会把用户已有的验收清单整个抹掉

给 AcceptanceLedger 加了非可选的 authorizedCommands 之后,所有已存在的
清单文件当场解码失败 —— Swift 合成的 Codable 要求非可选字段必须存在。
而读取是 try? 静默吞错误的,于是下一次写入从空清单开始,把内容整个覆盖掉,
全程无声无息。

真丢了数据:本机 9 个项目里,claude-hub 的 13 条和 storefront-a 的 1 条没了,
另有 demo-release 的 7 条正等着被同样地清空(赶在它之前修好了,
实测那 7 条经历一次真实写入后完整存活)。

我在 HookEvent 那边特意把新字段都设成可选并写了兼容性测试,却没对持久化的
ledger 做同样的事。而 ledger 的后果严重得多:hook 事件丢一条无所谓,
用户攒几周的清单没了是真损失。

三层修复:

1. 手写 init(from:),每个字段 decodeIfPresent —— 加字段不再让老数据全废。
2. 逐条解码要点,坏的跳过。原来一条坏的会让整个数组抛错,整份清单报废。
   同 TaskStateReader 的处理:边跑边写的数据撞上半条是正常情况。
3. **解不出来的文件先改名保住,绝不静默覆盖。** 丢数据和读不出来是两个
   量级的事故 —— 后者还能捞回来。

391 个测试,三条都有对应的回归测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 5 committed 2026-08-07 20:33:55 +08:00
1 parent a5ba3f7573
commit 78d24c934f
3 files changed
+181 -7

No files matched your search

+73
View File
@@ -138,6 +138,25 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
self.baselineCommit = baselineCommit
}
/// 手写解码,理由同 `AcceptanceLedger.init(from:)`:加字段不能让老数据全废。
///
/// 只有 `id` / `text` / `origin` 是必须的 —— 少了它们这条要点就没有意义了。
/// 其余一律给默认值。
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
origin = try container.decode(Origin.self, forKey: .origin)
acceptance = try container.decodeIfPresent(String.self, forKey: .acceptance)
status = try container.decodeIfPresent(Status.self, forKey: .status) ?? .open
evidence = try container.decodeIfPresent([Evidence].self, forKey: .evidence) ?? []
note = try container.decodeIfPresent(String.self, forKey: .note)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date()
sourceSessionId = try container.decodeIfPresent(String.self, forKey: .sourceSessionId)
baselineCommit = try container.decodeIfPresent(String.self, forKey: .baselineCommit)
}
/// 用户终裁过的项。机器结论一律盖不掉 —— 见 `AcceptanceStore.applyAudit`。
public var isSettledByUser: Bool { status == .accepted || status == .dropped }
@@ -162,6 +181,33 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
///
/// `UserPromptSubmit` 一到就零成本落这里,**不调模型**。拆解是异步消费这个缓冲的,
/// 因为拆解要花好几秒,而那个 hook 必须立刻返回。
/// 逐条解码的要点数组:坏的那条跳过,其余照常。
///
/// Swift 没有内置的"容错数组解码",只能自己包一层。
private struct LossyItems: Decodable {
let values: [AcceptanceItem]
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var collected: [AcceptanceItem] = []
while !container.isAtEnd {
// 解不出来时也必须**消费掉**这一个元素,否则 isAtEnd 永远不为真 ——
// 死循环。用一个空的占位类型把游标推过去。
if let item = try? container.decode(AcceptanceItem.self) {
collected.append(item)
} else {
_ = try? container.decode(Skip.self)
}
}
values = collected
}
/// 什么都不读,只为了让 unkeyedContainer 的游标前进一格。
private struct Skip: Decodable {
init(from decoder: Decoder) throws {}
}
}
public struct RawPrompt: Codable, Sendable, Equatable {
public let text: String
public let sessionId: String
@@ -208,6 +254,33 @@ public struct AcceptanceLedger: Codable, Sendable, Equatable {
self.updatedAt = updatedAt
}
/// **手写解码,每个字段都 decodeIfPresent。**
///
/// 合成的 Codable 对非可选字段要求必须存在 —— 给这个结构体加一个新字段,
/// 所有已存在的清单文件当场全部解码失败。而 `AcceptanceStore` 的读取是
/// `try?` 静默吞错误的,于是下一次写入会从空清单开始,**把用户攒的东西
/// 整个覆盖掉**。
///
/// 这不是假想:加 `authorizedCommands` 那次就这么丢了一份 13 条的清单,
/// 而且当时还有两个项目的文件正等着被同样地清空。
///
/// 清单是长期累积的数据,向后兼容不是可选项。以后加字段照着这里写。
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
projectPath = try container.decode(String.self, forKey: .projectPath)
// 逐条解,坏的跳过。
//
// 直接 `decode([AcceptanceItem].self)` 的话,**一条坏了整份清单就报废** ——
// 而报废的后果是这个项目的几十条要点全部读不出来。同 TaskStateReader
// 的处理:清单是边跑边写的,撞上半条数据是正常情况,不是异常。
items = try container.decodeIfPresent(LossyItems.self, forKey: .items)?.values ?? []
rawPrompts = try container.decodeIfPresent([RawPrompt].self, forKey: .rawPrompts) ?? []
authorizedCommands = try container.decodeIfPresent(
[String].self, forKey: .authorizedCommands
) ?? []
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date()
}
public var isEmpty: Bool { items.isEmpty && rawPrompts.isEmpty }
// MARK: - 汇报级统计
+33 -7
View File
@@ -407,13 +407,31 @@ public final class AcceptanceStore {
)
}
/// 从盘上重读一份。文件不存在或坏了就保持内存里的那份不动。
/// 从盘上重读一份。文件不存在就什么都不做。
///
/// **文件存在但解不出来时,先把它改名保住,再让调用方从空的开始。**
///
/// 早先这里是 `try?` 一路吞到底:解码失败 → 内存里是空的 → 下一次写入
/// 把用户攒了几周的清单整个覆盖掉,全程无声无息。真丢过一份 13 条的。
///
/// 改名到 `.broken-<时间戳>` 之后,最坏情况也只是"这次读不出来",
/// 数据还在盘上能捞回来。**丢数据和读不出来是两个量级的事故。**
private func reload(_ projectPath: String) {
guard let url = fileURL(for: projectPath),
let data = try? Data(contentsOf: url),
let ledger = try? JSONDecoder().decode(AcceptanceLedger.self, from: data)
let data = try? Data(contentsOf: url)
else { return }
ledgers[projectPath] = ledger
if let ledger = try? JSONDecoder().decode(AcceptanceLedger.self, from: data) {
ledgers[projectPath] = ledger
return
}
let rescued = url.deletingPathExtension()
.appendingPathExtension("broken-\(Int(Date().timeIntervalSince1970))")
try? FileManager.default.moveItem(at: url, to: rescued)
HubLog.app.error("""
验收清单解不出来,已保留为 \(rescued.lastPathComponent, privacy: .public)
""")
}
private func persist(_ ledger: AcceptanceLedger) {
@@ -433,9 +451,17 @@ public final class AcceptanceStore {
at: directory, includingPropertiesForKeys: nil
)) ?? []
for file in files where file.pathExtension == "json" {
guard let data = try? Data(contentsOf: file),
let ledger = try? JSONDecoder().decode(AcceptanceLedger.self, from: data)
else { continue }
guard let data = try? Data(contentsOf: file) else { continue }
guard let ledger = try? JSONDecoder().decode(AcceptanceLedger.self, from: data) else {
// 同 reload:解不出来的先保住,别等下一次写入把它盖掉。
let rescued = file.deletingPathExtension()
.appendingPathExtension("broken-\(Int(Date().timeIntervalSince1970))")
try? FileManager.default.moveItem(at: file, to: rescued)
HubLog.app.error("""
启动时有清单解不出来,已保留为 \(rescued.lastPathComponent, privacy: .public)
""")
continue
}
ledgers[ledger.projectPath] = ledger
}
}
@@ -1,4 +1,5 @@
import HubCore
import HubProjects
import XCTest
@testable import HubUI
@@ -346,6 +347,80 @@ final class AcceptanceStoreTests: XCTestCase {
XCTAssertTrue(texts.contains("app 后来又加的"))
}
/// **老版本写的清单(缺新字段)必须能读出来。**
///
/// 这条来自一次真实的数据丢失:给 `AcceptanceLedger` 加了非可选的
/// `authorizedCommands` 之后,所有已存在的文件当场解码失败 ——
/// 而读取是 `try?` 静默吞错误的,于是下一次写入从空清单开始,
/// 把一份 13 条的清单整个覆盖掉,全程无声无息。
///
/// 清单是长期累积的数据,向后兼容不是可选项。
/// 以后再加字段,这条测试会先红。
func testReadsLedgersWrittenByOlderVersions() throws {
let url = tempDir.appendingPathComponent(
UsageStats.encodeDirectoryName(for: project) + ".json"
)
// 一份"老版本"的清单:没有 authorizedCommands,条目里也没有
// status / evidence / createdAt 这些后来才加的字段。
let legacy = """
{"projectPath":"\(project)","rawPrompts":[],"updatedAt":800000000,
"items":[{"id":"i1","text":"老数据要还在","origin":"userPrompt"}]}
"""
try legacy.data(using: .utf8)!.write(to: url)
let store = AcceptanceStore(directory: tempDir)
XCTAssertEqual(store.ledger(for: project).items.first?.text, "老数据要还在")
XCTAssertEqual(store.ledger(for: project).items.first?.status, .open, "缺失的字段要有默认值")
XCTAssertEqual(store.ledger(for: project).authorizedCommands, [])
}
/// **一条要点坏了,不能连累同一份清单里其余的。**
///
/// 直接 `decode([AcceptanceItem].self)` 时一条坏的会让整个数组抛错,
/// 于是整份清单报废 —— 一个项目几十条要点全读不出来。
/// 同 `TaskStateReader` 的处理:边跑边写的数据,撞上半条是正常情况。
func testOneBrokenItemDoesNotKillTheRest() throws {
let url = tempDir.appendingPathComponent(
UsageStats.encodeDirectoryName(for: project) + ".json"
)
// 中间那条缺 text(必填),且 origin 是个没见过的值。
let mixed = """
{"projectPath":"\(project)","items":[
{"id":"a","text":"好的第一条","origin":"userPrompt"},
{"id":"b","origin":"来自未来的版本"},
{"id":"c","text":"好的第三条","origin":"manual"}
]}
"""
try mixed.data(using: .utf8)!.write(to: url)
let store = AcceptanceStore(directory: tempDir)
XCTAssertEqual(
store.ledger(for: project).items.map(\.text), ["好的第一条", "好的第三条"],
"坏的那条跳过就行,不能连累另外两条"
)
}
/// **解不出来的文件绝不能被静默覆盖。**
///
/// 丢数据和读不出来是两个量级的事故。读不出来还能捞回来,
/// 被覆盖掉就真没了。
func testUndecodableLedgerIsPreservedNotOverwritten() throws {
let url = tempDir.appendingPathComponent(
UsageStats.encodeDirectoryName(for: project) + ".json"
)
try #"{"projectPath":"x","items":[{"broken"#.data(using: .utf8)!.write(to: url)
let store = AcceptanceStore(directory: tempDir)
store.add(pendingItem("新的一条"), to: project)
let rescued = try FileManager.default
.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.contains("broken") }
XCTAssertEqual(rescued.count, 1, "解不出来的原文件必须被保住,而不是被盖掉")
}
/// 单个文件坏掉不能让整个功能失效 —— 清单是边跑边写的,
/// 撞上半截 JSON 是正常情况。
func testCorruptFileIsSkippedWithoutLosingTheOthers() throws {