diff --git a/bun.lock b/bun.lock index a86571ba07..59bd1574e3 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/app/package.json b/packages/app/package.json index a4773e4da2..98f37c764f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -46,6 +46,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/app/src/utils/id-wrap-boundary.test.ts b/packages/app/src/utils/id-wrap-boundary.test.ts new file mode 100644 index 0000000000..5e26a1ab08 --- /dev/null +++ b/packages/app/src/utils/id-wrap-boundary.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { create } from "@opencode-ai/schema/identifier" + +const WRAP_BOUNDARY = 1786706395136 +const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +describe("app identifier seam: 48-bit wrap", () => { + test("ascending ids stay lexicographically ascending across the wrap boundary", () => { + const preWrap = create(false, WRAP_BOUNDARY - 1) + const postWrap = create(false, WRAP_BOUNDARY + 1) + expect(preWrap < postWrap).toBe(true) + }) + + test("descending ids stay lexicographically descending across the wrap boundary", () => { + const preWrap = create(true, WRAP_BOUNDARY - 1) + const postWrap = create(true, WRAP_BOUNDARY + 1) + expect(postWrap < preWrap).toBe(true) + }) + + test("same-millisecond ids are strictly ascending and unique", () => { + const ids = Array.from({ length: 10 }, (_, index) => create(false, WRAP_BOUNDARY + 1000 + index)) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1] < ids[i]).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("same-millisecond descending ids are strictly descending and unique", () => { + const ids = Array.from({ length: 10 }, (_, index) => create(true, WRAP_BOUNDARY + 2000 + index)) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1] > ids[i]).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("id format is 12 hex chars plus 14 base62 chars", () => { + const id = create(false, WRAP_BOUNDARY + 1) + expect(id).toHaveLength(26) + expect(id.slice(0, 12)).toMatch(/^[0-9a-f]{12}$/) + for (const char of id.slice(12)) { + expect(chars).toContain(char) + } + }) + + test("ids stay ascending when the clock regresses (latch absorbs regression)", () => { + const first = create(false, WRAP_BOUNDARY + 3000) + const regressed = create(false, WRAP_BOUNDARY + 3000 - 50) + const later = create(false, WRAP_BOUNDARY + 3000 + 50) + expect(first < regressed).toBe(true) + expect(regressed < later).toBe(true) + }) + + test("new-scheme ids sort below historical pre-wrap ids (comparisons must be time-based)", () => { + const now = create(false, Date.now()) + const historical = create(false, WRAP_BOUNDARY - 1) + expect(now < historical).toBe(true) + }) +}) diff --git a/packages/app/src/utils/id.test.ts b/packages/app/src/utils/id.test.ts new file mode 100644 index 0000000000..00d7b8af93 --- /dev/null +++ b/packages/app/src/utils/id.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { create } from "@opencode-ai/core/id/id" +import { Identifier } from "./id" + +const prefixes = { + session: "ses", + message: "msg", + permission: "per", + user: "usr", + part: "prt", + pty: "pty", +} as const + +function decodeTime(id: string): number { + const start = id.indexOf("_") + 1 + return Number(BigInt("0x" + id.slice(start, start + 12))) +} + +describe("Identifier", () => { + test("every prefix keeps the prefix_underscore_26-char shape", () => { + for (const prefix of ["session", "message", "permission", "user", "part", "pty"] as const) { + const ascending = Identifier.ascending(prefix) + const descending = Identifier.descending(prefix) + expect(ascending.startsWith(`${prefixes[prefix]}_`)).toBe(true) + expect(descending.startsWith(`${prefixes[prefix]}_`)).toBe(true) + for (const id of [ascending, descending]) { + expect(id).toHaveLength(prefixes[prefix].length + 1 + 26) + expect(id.slice(prefixes[prefix].length + 1, prefixes[prefix].length + 13)).toMatch(/^[0-9a-f]{12}$/) + } + } + }) + + test("ascending ids decode their time prefix back to raw wall-clock ms", () => { + // The legacy encoding shifted the millisecond value 12 bits and wrapped at + // 2026-08-14 (issue 271), so under it a live id no longer decodes near + // Date.now(). + const before = Date.now() + const id = Identifier.ascending("message") + const after = Date.now() + const decoded = decodeTime(id) + expect(decoded).toBeGreaterThanOrEqual(before - 5000) + expect(decoded).toBeLessThanOrEqual(after + 5000) + }) + + test("app ids sort consistently with core ids for the same wall-clock", () => { + const appID = Identifier.ascending("message") + const coreID = create("msg", "ascending") + expect(Math.abs(decodeTime(appID) - decodeTime(coreID))).toBeLessThanOrEqual(5000) + }) + + test("same-millisecond bursts stay strictly ascending and unique", () => { + const ids = Array.from({ length: 20 }, () => Identifier.ascending("message")) + for (let index = 1; index < ids.length; index++) { + expect(ids[index - 1] < ids[index]).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("descending ids stay strictly descending and unique", () => { + const ids = Array.from({ length: 20 }, () => Identifier.descending("session")) + for (let index = 1; index < ids.length; index++) { + expect(ids[index - 1] > ids[index]).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("given id passes through when the prefix matches", () => { + expect(Identifier.ascending("session", "ses_abc123")).toBe("ses_abc123") + expect(Identifier.descending("message", "msg_abc123")).toBe("msg_abc123") + }) + + test("given id with a wrong prefix throws", () => { + expect(() => Identifier.ascending("message", "ses_abc123")).toThrow("ID ses_abc123 does not start with msg") + }) +}) diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts index dba7a8d951..0f4e363568 100644 --- a/packages/app/src/utils/id.ts +++ b/packages/app/src/utils/id.ts @@ -1,3 +1,5 @@ +import { create } from "@opencode-ai/schema/identifier" + const prefixes = { session: "ses", message: "msg", @@ -7,10 +9,6 @@ const prefixes = { pty: "pty", } as const -const LENGTH = 26 -let lastTimestamp = 0 -let counter = 0 - type Prefix = keyof typeof prefixes export namespace Identifier { export function ascending(prefix: Prefix, given?: string) { @@ -24,7 +22,7 @@ export namespace Identifier { function generateID(prefix: Prefix, descending: boolean, given?: string): string { if (!given) { - return create(prefix, descending) + return prefixes[prefix] + "_" + create(descending) } if (!given.startsWith(prefixes[prefix])) { @@ -33,61 +31,3 @@ function generateID(prefix: Prefix, descending: boolean, given?: string): string return given } - -function create(prefix: Prefix, descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - - counter += 1 - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - if (descending) { - now = ~now - } - - const timeBytes = new Uint8Array(6) - for (let i = 0; i < 6; i += 1) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12) -} - -function bytesToHex(bytes: Uint8Array): string { - let hex = "" - for (let i = 0; i < bytes.length; i += 1) { - hex += bytes[i].toString(16).padStart(2, "0") - } - return hex -} - -function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - const bytes = getRandomBytes(length) - let result = "" - for (let i = 0; i < length; i += 1) { - result += chars[bytes[i] % 62] - } - return result -} - -function getRandomBytes(length: number): Uint8Array { - const bytes = new Uint8Array(length) - const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined - - if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { - cryptoObj.getRandomValues(bytes) - return bytes - } - - for (let i = 0; i < length; i += 1) { - bytes[i] = Math.floor(Math.random() * 256) - } - - return bytes -}