From 286c2212b8644b2d6865849e2813850f7c4833b0 Mon Sep 17 00:00:00 2001 From: whitescan Date: Wed, 19 Aug 2026 12:48:13 +0900 Subject: [PATCH 1/2] fix: restore Antigravity usage parsing --- .../Core/LocalAntigravityUsageReader.swift | 69 ++++++++++++++-- .../AntigravityUsageTests.swift | 82 ++++++++++++++++++- docs/reference/defect-log.md | 5 ++ 3 files changed, 148 insertions(+), 8 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index 18c91a850..9a0fb16c6 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -236,6 +236,11 @@ enum LocalAntigravityUsageReader { } defer { sqlite3_finalize(statement) } + let stepDates = generationStepDates(handle) + if let status = stepDates.status { + return .incompleteScan(status: status, rows: 0) + } + let conversation = database.deletingPathExtension().lastPathComponent var entries: [LocalUsageReader.Entry] = [] var discardedCounters = 0 @@ -251,7 +256,8 @@ enum LocalAntigravityUsageReader { guard count > 0 else { return } let blob = Data(bytes: pointer, count: count) let record = parseGenerationMetadata( - blob, conversation: conversation, index: index, formatter: formatter) + blob, conversation: conversation, index: index, formatter: formatter, + stepDates: stepDates.dates) discardedCounters += record.discardedCounters guard let entry = record.entry else { return } entries.append(entry) @@ -267,6 +273,45 @@ enum LocalAntigravityUsageReader { return .complete(entries: entries, discardedCounters: discardedCounters) } + /// Current Antigravity stores keep the generation's timestamp in the corresponding + /// `steps.metadata` row rather than in `gen_metadata.data`. The response id is present in + /// both records, so the lookup does not depend on either table's row numbering. + private static func generationStepDates( + _ handle: OpaquePointer + ) -> (dates: [String: Date], status: Int32?) { + var statement: OpaquePointer? + let prepared = sqlite3_prepare_v2( + handle, + "SELECT metadata FROM steps WHERE step_type = 15 AND metadata IS NOT NULL", + -1, + &statement, + nil) + guard prepared == SQLITE_OK, let statement else { + // Older/minimal stores may not have a steps table. Direct timestamps in the + // generation blob still work in that case; a different SQLite error is retriable. + return ([:], prepared == SQLITE_ERROR ? nil : prepared) + } + defer { sqlite3_finalize(statement) } + + var dates: [String: Date] = [:] + while true { + let step = sqlite3_step(statement) + if step == SQLITE_ROW { + guard let pointer = sqlite3_column_blob(statement, 0) else { continue } + let count = Int(sqlite3_column_bytes(statement, 0)) + guard count > 0 else { continue } + let bytes = ArraySlice(Data(bytes: pointer, count: count)) + guard let model = AntigravityProto.message(bytes, field: 9), + let responseID = AntigravityProto.string(model, field: 11), + let stamp = AntigravityProto.message(bytes, field: 1), + let date = timestampDate(stamp) else { continue } + dates[responseID] = date + continue + } + return (dates, step == SQLITE_DONE ? nil : step) + } + } + /// `mode=ro` cannot create the `-shm` file a WAL database needs, so it fails outright on /// conversations that have no `-wal` sibling yet. `immutable=1` reads those, and is only /// reached when there is no `-wal` — that is, when there is no uncommitted tail to miss. @@ -305,16 +350,21 @@ enum LocalAntigravityUsageReader { _ blob: Data, conversation: String, index: Int64, - formatter: DateFormatter + formatter: DateFormatter, + stepDates: [String: Date] = [:] ) -> Record { let bytes = [UInt8](blob) guard let chatModel = AntigravityProto.message(bytes[...], field: 1), - let usage = AntigravityProto.message(chatModel, field: 4), - let date = createdAt(chatModel) else { return Record() } + let usage = AntigravityProto.message(chatModel, field: 4) else { return Record() } + + let responseID = AntigravityProto.string(usage, field: 11) + guard let date = createdAt(chatModel) ?? responseID.flatMap({ stepDates[$0] }) else { + return Record() + } // The turn's own id, not the file it happens to sit in — a copied conversation must // not read as fresh spend. `response_id` is populated on every recorded call. - let identity = AntigravityProto.string(usage, field: 11).map { "antigravity|\($0)" } + let identity = responseID.map { "antigravity|\($0)" } ?? "antigravity|\(conversation)|\(index)" // `response_model` names the model that answered; the rate lookup is short-circuited @@ -342,8 +392,13 @@ enum LocalAntigravityUsageReader { /// `chat_start_metadata.created_at`, a `google.protobuf.Timestamp`. private static func createdAt(_ chatModel: ArraySlice) -> Date? { guard let start = AntigravityProto.message(chatModel, field: 9), - let stamp = AntigravityProto.message(start, field: 4), - let seconds = AntigravityProto.varint(stamp, field: 1) else { return nil } + let stamp = AntigravityProto.message(start, field: 4) else { return nil } + return timestampDate(stamp) + } + + /// A `google.protobuf.Timestamp` with the same bounds used by the legacy generation path. + private static func timestampDate(_ stamp: ArraySlice) -> Date? { + guard let seconds = AntigravityProto.varint(stamp, field: 1) else { return nil } // A malformed varint can carry the whole uint64 range; a date built from it would // overflow downstream arithmetic. Anything outside a plausible window is not a time. guard seconds >= 1_000_000_000, seconds <= 4_102_444_800 else { return nil } diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index 71aa5d3fb..d20e2c371 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -108,6 +108,41 @@ final class AntigravityUsageTests: XCTestCase { XCTAssertEqual(entry.localDay, LocalUsageReader.localDayFormatter().string(from: created)) } + /// New Antigravity stores omit `chat_start_metadata.created_at` from the generation blob. + /// The same response id and timestamp remain in the corresponding generation step metadata. + func testCurrentGenerationFormatUsesStepMetadataTimestamp() throws { + let firstID = "synthetic-new-format-call-1" + let secondID = "synthetic-new-format-call-2" + let firstDate = try date("2026-08-19T10:00:00Z") + let secondDate = try date("2026-08-19T10:01:00Z") + try writeConversation( + "c1", + blobs: [ + currentFormatRecord(responseID: firstID, createdAt: firstDate, + input: 100, output: 20, cacheRead: 300), + currentFormatRecord(responseID: secondID, createdAt: secondDate, + input: 200, output: 30, cacheRead: 400), + ], + // Deliberately reverse the rows: correlation must use response_id, not table order. + stepMetadata: [ + stepMetadata(responseID: secondID, createdAt: secondDate), + stepMetadata(responseID: firstID, createdAt: firstDate), + ]) + + let entries = readAll() + XCTAssertEqual(entries.count, 2) + let byID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let first = try XCTUnwrap(byID["antigravity|\(firstID)"]) + let second = try XCTUnwrap(byID["antigravity|\(secondID)"]) + XCTAssertEqual(first.date.timeIntervalSince1970, firstDate.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(second.date.timeIntervalSince1970, secondDate.timeIntervalSince1970, accuracy: 0.001) + let formatter = LocalUsageReader.localDayFormatter() + XCTAssertEqual(first.localDay, formatter.string(from: firstDate)) + XCTAssertEqual(second.localDay, formatter.string(from: secondDate)) + XCTAssertEqual(first.total, 420) + XCTAssertEqual(second.total, 630) + } + func testRecordsBeforeTheWindowAreExcluded() throws { try writeConversation("c1", records: [ record(responseID: "old", model: "gemini-3.6-flash", createdAt: try date("2026-03-01T10:00:00Z"), @@ -614,6 +649,45 @@ final class AntigravityUsageTests: XCTestCase { return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) } + /// Minimal current-format record: the generation blob keeps usage/identity but no longer + /// carries `chat_start_metadata.created_at`. + private func currentFormatRecord( + responseID: String, + createdAt: Date, + input: UInt64, + output: UInt64, + cacheRead: UInt64 + ) -> Data { + var usage = AntigravityProto.encodeVarint(field: 2, input) + usage += AntigravityProto.encodeVarint(field: 3, output) + usage += AntigravityProto.encodeVarint(field: 5, cacheRead) + usage += AntigravityProto.encodeString(field: 11, responseID) + + // `chat_start_metadata` now contains other metadata at field 10, but not field 4 + // (`created_at`). It is included only to keep the fixture on the new wire shape. + var start = AntigravityProto.encodeVarint(field: 2, 1) + start += AntigravityProto.encodeMessage( + field: 10, + AntigravityProto.encodeVarint(field: 1, 1) + + AntigravityProto.encodeVarint(field: 4, 256_000)) + + var chatModel = AntigravityProto.encodeMessage(field: 4, usage) + chatModel += AntigravityProto.encodeMessage(field: 9, start) + chatModel += AntigravityProto.encodeString(field: 19, "gemini-3.6-flash") + return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) + } + + /// Minimal `steps.metadata` record used by the current writer. The timestamp and response + /// id are the only fields needed to correlate it with a generation metadata row. + private func stepMetadata(responseID: String, createdAt: Date) -> Data { + let timestamp = AntigravityProto.encodeVarint( + field: 1, UInt64(createdAt.timeIntervalSince1970)) + var metadata = AntigravityProto.encodeMessage(field: 1, timestamp) + metadata += AntigravityProto.encodeMessage( + field: 9, AntigravityProto.encodeString(field: 11, responseID)) + return Data(metadata) + } + private func writeConversation(_ name: String, records: [Data], walMode: Bool = false) throws { try writeConversation(name, blobs: records, walMode: walMode) } @@ -675,7 +749,7 @@ final class AntigravityUsageTests: XCTestCase { } private func writeConversation(_ name: String, blobs: [Data], walMode: Bool = false, - pageSize: Int? = nil) throws { + pageSize: Int? = nil, stepMetadata: [Data] = []) throws { let database = temporaryDirectory.appendingPathComponent("\(name).db") // `page_size` only takes effect before the first table exists. var sql = pageSize.map { "PRAGMA page_size=\($0);\n" } ?? "" @@ -684,6 +758,12 @@ final class AntigravityUsageTests: XCTestCase { for (index, blob) in blobs.enumerated() { sql += "INSERT INTO gen_metadata VALUES (\(index), X'\(blob.map { String(format: "%02x", $0) }.joined())', \(blob.count));\n" } + if !stepMetadata.isEmpty { + sql += "CREATE TABLE steps (idx integer, step_type integer NOT NULL DEFAULT 0, metadata blob, PRIMARY KEY (idx));\n" + for (index, metadata) in stepMetadata.enumerated() { + sql += "INSERT INTO steps VALUES (\(index), 15, X'\(metadata.map { String(format: "%02x", $0) }.joined())');\n" + } + } try execute(database, sql: sql) } diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 34c05ae16..ac039b340 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -53,6 +53,11 @@ read_when: 계약 테스트)를 열어 키·타입·의미를 확정 ② 그 계약으로 픽스처 작성 ③ 가능하면 실파일 1건 캡처. 특히 **같은 스펠링이 표면마다 의미가 다를 수 있다**(Grok `inputTokens`=캐시 포함 durable wire vs `input_tokens`=캐시 제외 헤드리스 투영) — 별칭으로 합치면 캐시분을 두 번 빼거나 두 번 더한다. +- **Antigravity의 생성 시각은 `gen_metadata` 한 곳에 고정돼 있지 않다.** 구 포맷은 + `chat_start_metadata.created_at`에 시각을 넣지만, 현재 포맷은 같은 `response_id`를 가진 + `steps(step_type=15).metadata`에 타임스탬프를 둔다. 토큰 필드는 유지되므로 `gen_metadata`만 읽으면 + 오늘 사용량이 통째로 `nil`이 된다. 두 레코드의 행 순서가 같다는 가정 대신 `response_id`로 연결하고, + 구 포맷의 직접 시각은 계속 우선한다. 회귀 가드는 `testCurrentGenerationFormatUsesStepMetadataTimestamp`다. - **사용량 소스의 "복사·재기록" 경로를 먼저 찾아라 (이중집계·재날짜화).** 세션 fork·재생·서브에이전트는 같은 지출을 여러 파일에 남기거나 시각을 다시 찍는다. 규칙: ① dedup 키는 *턴 자체* 의 전역 유일 id(파일·세션 경로를 섞지 마라 — 복사본이 별건이 된다) ② 시각은 *기록* 시각이 아니라 *턴* 시각(fork 는 봉투 timestamp 를 새로 From 9f4f653467de8f894a7569b4dd730a89a1e6492d Mon Sep 17 00:00:00 2001 From: whitescan Date: Wed, 19 Aug 2026 13:23:55 +0900 Subject: [PATCH 2/2] test: cover observed Antigravity protobuf correlation --- .../AntigravityUsageTests.swift | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index d20e2c371..a3f02d28c 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -109,24 +109,28 @@ final class AntigravityUsageTests: XCTestCase { } /// New Antigravity stores omit `chat_start_metadata.created_at` from the generation blob. - /// The same response id and timestamp remain in the corresponding generation step metadata. + /// The generation still carries an `execution_id` at field 4, but that id is shared by + /// multiple generations. The response id remains the per-generation correlation key at + /// `field 1 → field 4 → field 11`; the step metadata mirrors it at `field 9 → field 11`. func testCurrentGenerationFormatUsesStepMetadataTimestamp() throws { let firstID = "synthetic-new-format-call-1" let secondID = "synthetic-new-format-call-2" + let executionID = "00000000-0000-0000-0000-000000000001" let firstDate = try date("2026-08-19T10:00:00Z") let secondDate = try date("2026-08-19T10:01:00Z") try writeConversation( "c1", blobs: [ - currentFormatRecord(responseID: firstID, createdAt: firstDate, + currentFormatRecord(responseID: firstID, executionID: executionID, createdAt: firstDate, input: 100, output: 20, cacheRead: 300), - currentFormatRecord(responseID: secondID, createdAt: secondDate, + currentFormatRecord(responseID: secondID, executionID: executionID, createdAt: secondDate, input: 200, output: 30, cacheRead: 400), ], - // Deliberately reverse the rows: correlation must use response_id, not table order. + // Deliberately reverse the rows: correlation must use response_id, not execution_id + // or table order. stepMetadata: [ - stepMetadata(responseID: secondID, createdAt: secondDate), - stepMetadata(responseID: firstID, createdAt: firstDate), + stepMetadata(responseID: secondID, executionID: executionID, createdAt: secondDate), + stepMetadata(responseID: firstID, executionID: executionID, createdAt: firstDate), ]) let entries = readAll() @@ -143,6 +147,60 @@ final class AntigravityUsageTests: XCTestCase { XCTAssertEqual(second.total, 630) } + func testCurrentFormatPreservesEachRecordAcrossLocalMidnight() throws { + let firstID = "synthetic-midnight-call-1" + let secondID = "synthetic-midnight-call-2" + let executionID = "00000000-0000-0000-0000-000000000002" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let firstDate = try XCTUnwrap(calendar.date(from: DateComponents( + year: 2026, month: 8, day: 18, hour: 23, minute: 59))) + let secondDate = try XCTUnwrap(calendar.date(from: DateComponents( + year: 2026, month: 8, day: 19, hour: 0, minute: 1))) + + try writeConversation( + "c1", + blobs: [ + currentFormatRecord(responseID: firstID, executionID: executionID, createdAt: firstDate, + input: 100, output: 20, cacheRead: 300), + currentFormatRecord(responseID: secondID, executionID: executionID, createdAt: secondDate, + input: 200, output: 30, cacheRead: 400), + ], + stepMetadata: [ + stepMetadata(responseID: secondID, executionID: executionID, createdAt: secondDate), + stepMetadata(responseID: firstID, executionID: executionID, createdAt: firstDate), + ]) + + let entries = readAll() + let byID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let first = try XCTUnwrap(byID["antigravity|\(firstID)"]) + let second = try XCTUnwrap(byID["antigravity|\(secondID)"]) + let formatter = LocalUsageReader.localDayFormatter() + XCTAssertEqual(first.localDay, formatter.string(from: firstDate)) + XCTAssertEqual(second.localDay, formatter.string(from: secondDate)) + XCTAssertNotEqual(first.localDay, second.localDay) + } + + func testLegacyCreatedAtRemainsPreferredOverStepMetadataTimestamp() throws { + let responseID = "synthetic-legacy-precedence" + let legacyDate = try date("2026-08-18T10:00:00Z") + let stepDate = try date("2026-08-19T10:00:00Z") + try writeConversation( + "c1", + records: [ + record(responseID: responseID, model: "gemini-3.6-flash", createdAt: legacyDate, + input: 100, output: 20, cacheRead: 300), + ], + stepMetadata: [ + stepMetadata(responseID: responseID, + executionID: "00000000-0000-0000-0000-000000000003", + createdAt: stepDate), + ]) + + let entry = try XCTUnwrap(readAll().first) + XCTAssertEqual(entry.date.timeIntervalSince1970, legacyDate.timeIntervalSince1970, accuracy: 0.001) + } + func testRecordsBeforeTheWindowAreExcluded() throws { try writeConversation("c1", records: [ record(responseID: "old", model: "gemini-3.6-flash", createdAt: try date("2026-03-01T10:00:00Z"), @@ -653,6 +711,7 @@ final class AntigravityUsageTests: XCTestCase { /// carries `chat_start_metadata.created_at`. private func currentFormatRecord( responseID: String, + executionID: String, createdAt: Date, input: UInt64, output: UInt64, @@ -674,22 +733,28 @@ final class AntigravityUsageTests: XCTestCase { var chatModel = AntigravityProto.encodeMessage(field: 4, usage) chatModel += AntigravityProto.encodeMessage(field: 9, start) chatModel += AntigravityProto.encodeString(field: 19, "gemini-3.6-flash") - return Data(AntigravityProto.encodeMessage(field: 1, chatModel)) + var metadata = AntigravityProto.encodeMessage(field: 1, chatModel) + // Observed current writer shape: gen_metadata.data field 4 is the execution_id. + metadata += AntigravityProto.encodeString(field: 4, executionID) + return Data(metadata) } - /// Minimal `steps.metadata` record used by the current writer. The timestamp and response - /// id are the only fields needed to correlate it with a generation metadata row. - private func stepMetadata(responseID: String, createdAt: Date) -> Data { + /// Minimal `steps.metadata` record used by the current writer. Field 12 repeats the + /// execution_id group key; field 9 → field 11 carries the per-generation response id used + /// to correlate the timestamp with a generation metadata row. + private func stepMetadata(responseID: String, executionID: String, createdAt: Date) -> Data { let timestamp = AntigravityProto.encodeVarint( field: 1, UInt64(createdAt.timeIntervalSince1970)) var metadata = AntigravityProto.encodeMessage(field: 1, timestamp) metadata += AntigravityProto.encodeMessage( field: 9, AntigravityProto.encodeString(field: 11, responseID)) + metadata += AntigravityProto.encodeString(field: 12, executionID) return Data(metadata) } - private func writeConversation(_ name: String, records: [Data], walMode: Bool = false) throws { - try writeConversation(name, blobs: records, walMode: walMode) + private func writeConversation(_ name: String, records: [Data], walMode: Bool = false, + stepMetadata: [Data] = []) throws { + try writeConversation(name, blobs: records, walMode: walMode, stepMetadata: stepMetadata) } /// A store spanning several pages, so damaging the last one still leaves earlier rows