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:
1 parent
a5ba3f7573
commit
78d24c934f
3 files changed
+181
-7
No files matched your search
@@ -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: - 汇报级统计
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user