修复子代理与 fork 会话的 Token 全量丢失:原判定要求 task_started 带 started_at 且不早于会话创建时间才结束回放,但子代理的 started_at 是父级 回合的开始时间,天然早于子会话文件,新版 Codex 更是完全不发该字段。改为 首个 task_started 无条件结束回放——实测 661 个真实 rollout 中没有任何一个 的 token_count 出现在它之前。本机语料 A/B:Token 13.60G → 14.53G(+6.8%), 用量记录 68997 → 73566。 其他修复: - 两个 Provider 钉到专用串行执行器,冷扫描不再占用协作线程池。把池限制 成一条线程实测,修复前并行的每秒采样在 43 秒扫描期间一次都没能推进。 - 按日期与模型聚合并定序输出,行数 701 → 272 且 id 唯一;顺带修掉冷扫描 与检查点恢复顺序不一致导致每次刷新都产生"不相等"报告的问题。 - 缓存行加剪枝,覆盖归档搬走的 rollout、删除的项目目录与旧解析命名空间; 检查点仅在末行未完时存第二份状态,Claude 事件按保留窗口裁剪。 - 复用 ISO8601 解析器,不再逐行创建。 - 数字与日期改跟随应用语言,弹窗与菜单栏共用同一套 Token 缩写。 - 窄弹窗热力图按设计尺寸铺开后横向滚动,此前被钉在面板宽度上永不溢出。 - 打开菜单栏项一并开启扫描,并为无数据状态补上悬停文案。 - 删除未被应用构造的配额 Provider、凭据读取与响应映射(约 330 行)及其 全链路管线,并清掉 37 条孤立译文 × 8 个语言文件。
164 lines
8.3 KiB
Swift
164 lines
8.3 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
import SQLite3
|
|
|
|
/// 每个 Provider actor 独占一个连接。游标与解析状态在同一 SQLite 行中原子提交。
|
|
/// 状态只包含计数/模型/消息 ID,不持久化日志正文或未完成行的内容。
|
|
final class UsageScanStore {
|
|
static var defaultURL: URL {
|
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("XStats/ai-usage.sqlite")
|
|
}
|
|
private var db: OpaquePointer?
|
|
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
private(set) var bytesRead = 0
|
|
|
|
private struct Checkpoint<State: Codable>: Codable {
|
|
let size: UInt64
|
|
let modified: Date
|
|
let inode: UInt64
|
|
let timeZone: String
|
|
let offset: UInt64
|
|
let prefixHash: Data
|
|
let boundaryHash: Data
|
|
let state: State
|
|
/// 只有末行还没写完时才与 `state` 不同;相同就不存第二份,检查点因此小一半。
|
|
let preview: State?
|
|
}
|
|
|
|
init(url: URL) throws {
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
var handle: OpaquePointer?
|
|
guard sqlite3_open_v2(url.path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
|
let handle else {
|
|
sqlite3_close(handle); throw AIUsageFailure.invalidResponse
|
|
}
|
|
db = handle
|
|
sqlite3_busy_timeout(handle, 5_000)
|
|
guard sqlite3_exec(handle, """
|
|
PRAGMA journal_mode=WAL;
|
|
CREATE TABLE IF NOT EXISTS usage_scan_v1 (
|
|
source TEXT NOT NULL, path TEXT NOT NULL, payload BLOB NOT NULL,
|
|
PRIMARY KEY(source, path)
|
|
);
|
|
""", nil, nil, nil) == SQLITE_OK else {
|
|
sqlite3_close(handle); db = nil; throw AIUsageFailure.invalidResponse
|
|
}
|
|
}
|
|
deinit { sqlite3_close(db) }
|
|
|
|
func scan<State: Codable>(url: URL, source: String, initial: State,
|
|
consume: (inout State, Data) -> Void) throws -> State {
|
|
bytesRead = 0
|
|
let path = url.resolvingSymlinksInPath().path
|
|
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
|
let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
|
|
let inode = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0
|
|
let modified = attributes[.modificationDate] as? Date ?? .distantPast
|
|
let zone = Calendar.current.timeZone.identifier
|
|
let old = try load(source: source, path: path).flatMap { try? JSONDecoder().decode(Checkpoint<State>.self, from: $0) }
|
|
if let old, old.size == size, old.modified == modified, old.inode == inode, old.timeZone == zone {
|
|
return old.preview ?? old.state
|
|
}
|
|
let file = try FileHandle(forReadingFrom: url)
|
|
defer { try? file.close() }
|
|
func hash(_ start: UInt64, _ length: Int) throws -> Data {
|
|
try file.seek(toOffset: start)
|
|
let data = try file.read(upToCount: length) ?? Data()
|
|
bytesRead += data.count
|
|
return Data(SHA256.hash(data: data))
|
|
}
|
|
var offset: UInt64 = 0
|
|
var state = initial
|
|
// 只对正常追加复用基线。替换、截断、时区变更或边界内容改变时重建该文件。
|
|
if let old, size > old.size, inode == old.inode, old.timeZone == zone,
|
|
try hash(0, Int(min(4096, old.offset))) == old.prefixHash,
|
|
try hash(old.offset - min(4096, old.offset), Int(min(4096, old.offset))) == old.boundaryHash {
|
|
offset = old.offset; state = old.state
|
|
}
|
|
try file.seek(toOffset: offset)
|
|
var pending = Data()
|
|
var cursor = offset
|
|
var skipping = false
|
|
while cursor < size {
|
|
try Task.checkCancellation()
|
|
let chunk = try file.read(upToCount: Int(min(128 * 1024, size - cursor))) ?? Data()
|
|
guard !chunk.isEmpty else { throw AIUsageFailure.invalidResponse }
|
|
bytesRead += chunk.count; cursor += UInt64(chunk.count)
|
|
pending.append(chunk)
|
|
while let newline = pending.firstIndex(of: 10) {
|
|
if !skipping { consume(&state, Data(pending[..<newline])) }
|
|
let length = pending.distance(from: pending.startIndex, to: newline) + 1
|
|
offset += UInt64(length)
|
|
pending.removeSubrange(...newline); skipping = false
|
|
}
|
|
if pending.count > 8 * 1024 * 1024 {
|
|
// 跳过超长正文,直到遇到换行才允许提交游标。
|
|
offset += UInt64(pending.count); pending.removeAll(); skipping = true
|
|
}
|
|
}
|
|
var preview = state
|
|
let unfinished = !skipping && !pending.isEmpty
|
|
if unfinished { consume(&preview, pending) }
|
|
// 超长且尚未结束的行不提交,下一次仍从上一个检查点重试。
|
|
if skipping { return preview }
|
|
let prefix = try hash(0, Int(min(4096, offset)))
|
|
let boundary = try hash(offset - min(4096, offset), Int(min(4096, offset)))
|
|
let checkpoint = Checkpoint(size: size, modified: modified, inode: inode, timeZone: zone, offset: offset,
|
|
prefixHash: prefix, boundaryHash: boundary, state: state, preview: unfinished ? preview : nil)
|
|
try Task.checkCancellation()
|
|
try save(try JSONEncoder().encode(checkpoint), source: source, path: path)
|
|
return preview
|
|
}
|
|
|
|
/// 只保留本轮扫到的文件。归档搬走的 rollout、删掉的项目目录和旧解析命名空间
|
|
/// 都不会再出现在扫描结果里,留着只会让缓存库连同完整解析状态一起无限增长。
|
|
func prune(family: String, source: String, keeping paths: Set<String>) throws {
|
|
var query: OpaquePointer?
|
|
guard sqlite3_prepare_v2(db, "SELECT source,path FROM usage_scan_v1 WHERE source LIKE ?", -1, &query, nil) == SQLITE_OK,
|
|
let query else { throw AIUsageFailure.invalidResponse }
|
|
sqlite3_bind_text(query, 1, family + "%", -1, transient)
|
|
var stale: [(source: String, path: String)] = []
|
|
while sqlite3_step(query) == SQLITE_ROW {
|
|
guard let rawSource = sqlite3_column_text(query, 0), let rawPath = sqlite3_column_text(query, 1) else { continue }
|
|
let existing = String(cString: rawSource), path = String(cString: rawPath)
|
|
if existing != source || !paths.contains(path) { stale.append((existing, path)) }
|
|
}
|
|
sqlite3_finalize(query)
|
|
for row in stale {
|
|
let s = try statement("DELETE FROM usage_scan_v1 WHERE source=? AND path=?", source: row.source, path: row.path)
|
|
defer { sqlite3_finalize(s) }
|
|
guard sqlite3_step(s) == SQLITE_DONE else { throw AIUsageFailure.invalidResponse }
|
|
}
|
|
}
|
|
|
|
private func statement(_ sql: String, source: String, path: String) throws -> OpaquePointer {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
|
|
throw AIUsageFailure.invalidResponse
|
|
}
|
|
sqlite3_bind_text(statement, 1, source, -1, transient)
|
|
sqlite3_bind_text(statement, 2, path, -1, transient)
|
|
return statement
|
|
}
|
|
|
|
private func load(source: String, path: String) throws -> Data? {
|
|
let s = try statement("SELECT payload FROM usage_scan_v1 WHERE source=? AND path=?", source: source, path: path)
|
|
defer { sqlite3_finalize(s) }
|
|
let status = sqlite3_step(s)
|
|
if status == SQLITE_DONE { return nil }
|
|
guard status == SQLITE_ROW, let bytes = sqlite3_column_blob(s, 0) else { throw AIUsageFailure.invalidResponse }
|
|
return Data(bytes: bytes, count: Int(sqlite3_column_bytes(s, 0)))
|
|
}
|
|
|
|
private func save(_ data: Data, source: String, path: String) throws {
|
|
let s = try statement("INSERT OR REPLACE INTO usage_scan_v1 (source,path,payload) VALUES (?,?,?)", source: source, path: path)
|
|
defer { sqlite3_finalize(s) }
|
|
_ = data.withUnsafeBytes { sqlite3_bind_blob(s, 3, $0.baseAddress, Int32($0.count), transient) }
|
|
guard sqlite3_step(s) == SQLITE_DONE else { throw AIUsageFailure.invalidResponse }
|
|
}
|
|
}
|