From 4e8daf71d54d148553cc6f41458f9ff43e87dbc4 Mon Sep 17 00:00:00 2001 From: jianYanZhiX7 Date: Sat, 19 Sep 2026 17:38:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=20jsonl=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=88=B3=E6=94=B9=E4=B8=BA=E4=B8=8A=E6=B5=B7=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写入时将顶层 timestamp 序列化为 +08:00 偏移写法, 使会话文件可直接读作本地时间,同时保持 ISO-8601 严格性。 - 新增 src/utils/shanghaiTimestamp.ts:转换、改写与时刻比较 - 5 个写入点接入转换(主写入队列、远程水合、CCR v2 前台与子代理、追加写入) - 2 处排序改为按真实时刻比较,修正 Z 与 +08:00 混存时的字典序错序 - 嵌套时间戳不改写,避免污染 toolUseResult 与 MCP 元数据 - 补充 shanghaiTimestamp 单元测试(10 个用例) --- src/utils/__tests__/shanghaiTimestamp.test.ts | 83 +++++++++++++++++++ src/utils/sessionStorage.ts | 26 ++++-- src/utils/shanghaiTimestamp.ts | 61 ++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 src/utils/__tests__/shanghaiTimestamp.test.ts create mode 100644 src/utils/shanghaiTimestamp.ts diff --git a/src/utils/__tests__/shanghaiTimestamp.test.ts b/src/utils/__tests__/shanghaiTimestamp.test.ts new file mode 100644 index 0000000000..9512a51947 --- /dev/null +++ b/src/utils/__tests__/shanghaiTimestamp.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { + compareTimestamps, + toShanghaiISOString, + toShanghaiTimestamp, + withShanghaiTimestamp, +} from '../shanghaiTimestamp' + +describe('toShanghaiISOString', () => { + test('renders UTC instants with a +08:00 offset', () => { + expect(toShanghaiISOString(new Date('2026-09-19T09:23:26.000Z'))).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('represents the same instant as the source date', () => { + const date = new Date('2026-01-01T00:00:00.000Z') + expect(Date.parse(toShanghaiISOString(date))).toBe(date.getTime()) + }) +}) + +describe('toShanghaiTimestamp', () => { + test('converts a Z timestamp to +08:00', () => { + expect(toShanghaiTimestamp('2026-09-19T09:23:26.000Z')).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('leaves timestamps that already carry an offset untouched', () => { + expect(toShanghaiTimestamp('2026-09-19T17:23:26.000+08:00')).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('leaves unparseable values untouched', () => { + expect(toShanghaiTimestamp('not-a-dateZ')).toBe('not-a-dateZ') + }) +}) + +describe('withShanghaiTimestamp', () => { + test('rewrites the top-level timestamp without mutating the input', () => { + const entry = { uuid: 'u1', timestamp: '2026-09-19T09:23:26.000Z' } + expect(withShanghaiTimestamp(entry)).toEqual({ + uuid: 'u1', + timestamp: '2026-09-19T17:23:26.000+08:00', + }) + expect(entry.timestamp).toBe('2026-09-19T09:23:26.000Z') + }) + + test('leaves nested timestamps byte-for-byte intact', () => { + const nested = '2026-09-19T09:23:26.000Z' + const converted = withShanghaiTimestamp({ + timestamp: nested, + toolUseResult: { timestamp: nested }, + }) as { timestamp: string; toolUseResult: { timestamp: string } } + expect(converted.timestamp).toBe('2026-09-19T17:23:26.000+08:00') + expect(converted.toolUseResult.timestamp).toBe(nested) + }) + + test('passes through non-objects and entries without a string timestamp', () => { + expect(withShanghaiTimestamp(null)).toBeNull() + expect(withShanghaiTimestamp('x')).toBe('x') + expect(withShanghaiTimestamp([1])).toEqual([1]) + const noTimestamp = { uuid: 'u1' } + expect(withShanghaiTimestamp(noTimestamp)).toBe(noTimestamp) + }) +}) + +describe('compareTimestamps', () => { + test('orders mixed Z and +08:00 entries by instant, not lexically', () => { + const earlier = '2026-09-19T09:23:26.000Z' // 17:23 +08:00 + const later = '2026-09-19T18:23:26.000+08:00' // 10:23Z + expect(compareTimestamps(earlier, later)).toBeLessThan(0) + expect(compareTimestamps(later, earlier)).toBeGreaterThan(0) + expect(compareTimestamps(earlier, earlier)).toBe(0) + }) + + test('falls back to lexicographic order for unparseable values', () => { + expect(compareTimestamps('abc', 'abd')).toBeLessThan(0) + expect(compareTimestamps('abd', 'abc')).toBeGreaterThan(0) + expect(compareTimestamps('abc', 'abc')).toBe(0) + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index d6da26184a..6b73d64b83 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -91,6 +91,10 @@ import { SKIP_PRECOMPACT_THRESHOLD, } from './sessionStoragePortable.js' import { getSettings_DEPRECATED } from './settings/settings.js' +import { + compareTimestamps, + withShanghaiTimestamp, +} from './shanghaiTimestamp.js' import { jsonParse, jsonStringify } from './slowOperations.js' import type { ContentReplacementRecord } from './toolResultStorage.js' import { validateUuid } from './uuid.js' @@ -667,7 +671,7 @@ class Project { const resolvers: Array<() => void> = [] for (const { entry, resolve } of batch) { - const line = jsonStringify(entry) + '\n' + const line = jsonStringify(withShanghaiTimestamp(entry)) + '\n' if (content.length + line.length >= this.MAX_CHUNK_BYTES) { // Flush chunk and resolve its entries before starting a new one @@ -1641,7 +1645,9 @@ export async function hydrateRemoteSession( // Replace local logs with remote logs. writeFile truncates, so no // unlink is needed; an empty remoteLogs array produces an empty file. - const content = remoteLogs.map(e => jsonStringify(e) + '\n').join('') + const content = remoteLogs + .map(e => jsonStringify(withShanghaiTimestamp(e)) + '\n') + .join('') await writeFile(sessionFile, content, { encoding: 'utf8', mode: 0o600 }) logForDebugging(`Hydrated ${remoteLogs.length} entries from remote`) @@ -1693,7 +1699,9 @@ export async function hydrateFromCCRv2InternalEvents( // Write foreground transcript const sessionFile = getTranscriptPathForSession(sessionId) - const fgContent = events.map(e => jsonStringify(e.payload) + '\n').join('') + const fgContent = events + .map(e => jsonStringify(withShanghaiTimestamp(e.payload)) + '\n') + .join('') await writeFile(sessionFile, fgContent, { encoding: 'utf8', mode: 0o600 }) logForDebugging( @@ -1725,7 +1733,7 @@ export async function hydrateFromCCRv2InternalEvents( const agentFile = getAgentTranscriptPath(asAgentId(agentId)) await mkdir(dirname(agentFile), { recursive: true, mode: 0o700 }) const agentContent = entries - .map(p => jsonStringify(p) + '\n') + .map(p => jsonStringify(withShanghaiTimestamp(p)) + '\n') .join('') await writeFile(agentFile, agentContent, { encoding: 'utf8', @@ -2220,8 +2228,8 @@ function recoverOrphanedParallelToolResults( // Timestamp sort keeps content-block / completion order; stable-sort // preserves JSONL write order on ties. - orphanedSiblings.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) - orphanedTRs.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + orphanedSiblings.sort((a, b) => compareTimestamps(a.timestamp, b.timestamp)) + orphanedTRs.sort((a, b) => compareTimestamps(a.timestamp, b.timestamp)) const anchor = anchorByMsgId.get(msgId)! const recovered = [...orphanedSiblings, ...orphanedTRs] @@ -2613,7 +2621,7 @@ function appendEntryToFile( entry: Record, ): void { const fs = getFsImplementation() - const line = jsonStringify(entry) + '\n' + const line = jsonStringify(withShanghaiTimestamp(entry)) + '\n' try { fs.appendFileSync(fullPath, line, { mode: 0o600 }) } catch { @@ -4788,9 +4796,9 @@ export async function loadAllLogsFromSessionFile( // Append trailing messages that are children of the leaf const trailingMessages = childrenByParent.get(leafMessage.uuid) if (trailingMessages) { - // ISO-8601 UTC timestamps are lexically sortable + // Offset-aware: transcripts may mix legacy `...Z` with `+08:00` entries trailingMessages.sort((a, b) => - a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0, + compareTimestamps(a.timestamp, b.timestamp), ) chain.push(...trailingMessages) } diff --git a/src/utils/shanghaiTimestamp.ts b/src/utils/shanghaiTimestamp.ts new file mode 100644 index 0000000000..709313f282 --- /dev/null +++ b/src/utils/shanghaiTimestamp.ts @@ -0,0 +1,61 @@ +const SHANGHAI_UTC_OFFSET_MINUTES = 480 +const UTC_DESIGNATOR = 'Z' +const SHANGHAI_OFFSET_SUFFIX = '+08:00' + +/** + * Asia/Shanghai has held a fixed UTC+8 offset with no DST since 1991, so the + * constant 480-minute shift below is exact rather than an approximation. + */ +export function toShanghaiISOString(date: Date = new Date()): string { + const shifted = new Date( + date.getTime() + SHANGHAI_UTC_OFFSET_MINUTES * 60_000, + ) + return shifted.toISOString().replace(UTC_DESIGNATOR, SHANGHAI_OFFSET_SUFFIX) +} + +export function toShanghaiTimestamp(value: string): string { + if (!value.endsWith(UTC_DESIGNATOR)) { + return value + } + const ms = Date.parse(value) + if (Number.isNaN(ms)) { + return value + } + return toShanghaiISOString(new Date(ms)) +} + +/** + * Rewrites a transcript entry's top-level `timestamp` to Shanghai time. + * Nested timestamps are deliberately left alone: they occur inside tool + * results and MCP metadata, which are opaque payloads that must round-trip + * byte-for-byte. + */ +export function withShanghaiTimestamp(entry: unknown): unknown { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + return entry + } + const record = entry as Record + const timestamp = record.timestamp + if (typeof timestamp !== 'string') { + return entry + } + const converted = toShanghaiTimestamp(timestamp) + if (converted === timestamp) { + return entry + } + return { ...record, timestamp: converted } +} + +/** + * Chronological comparison that ignores the offset notation, so a transcript + * mixing legacy `...Z` entries with `+08:00` entries still orders correctly. + * Lexicographic comparison would not: `13:14Z` sorts after `21:14+08:00`. + */ +export function compareTimestamps(a: string, b: string): number { + const msA = Date.parse(a) + const msB = Date.parse(b) + if (Number.isNaN(msA) || Number.isNaN(msB)) { + return a < b ? -1 : a > b ? 1 : 0 + } + return msA - msB +}