diff --git a/HubKit/Sources/HubCore/Acceptance.swift b/HubKit/Sources/HubCore/Acceptance.swift index 00e8e6d..abc3c8e 100644 --- a/HubKit/Sources/HubCore/Acceptance.swift +++ b/HubKit/Sources/HubCore/Acceptance.swift @@ -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: - 汇报级统计 diff --git a/HubKit/Sources/HubUI/AcceptanceStore.swift b/HubKit/Sources/HubUI/AcceptanceStore.swift index 88d723f..f171dc3 100644 --- a/HubKit/Sources/HubUI/AcceptanceStore.swift +++ b/HubKit/Sources/HubUI/AcceptanceStore.swift @@ -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 } } diff --git a/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift b/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift index 56f8e4a..8b6a296 100644 --- a/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift +++ b/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift @@ -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 {