fix(app): reconcile session pages with concurrent events (#34042)
This commit is contained in:
parent
bdfea046db
commit
3d072112ce
4 changed files with 1734 additions and 123 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
|
|
@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: {
|
||||||
type: "message.part.delta",
|
type: "message.part.delta",
|
||||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
properties: { messageID: "msg", partID, field, delta: value },
|
||||||
} as Event,
|
} as Event,
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
test("merges adjacent deltas for the same field", () => {
|
||||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
const first = delta("hello ")
|
||||||
|
const second = delta("world")
|
||||||
|
first.payload.id = "first"
|
||||||
|
second.payload.id = "second"
|
||||||
|
const result = coalesceServerEvents([first, second])
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
|
|
@ -45,9 +49,113 @@ describe("coalesceServerEvents", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drops stale deltas", () => {
|
test("preserves event ID order across interleaved deltas", () => {
|
||||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
const first = delta("a")
|
||||||
|
const other = delta("b", "text", "other")
|
||||||
|
const last = delta("c")
|
||||||
|
first.payload.id = "1"
|
||||||
|
other.payload.id = "2"
|
||||||
|
last.payload.id = "3"
|
||||||
|
|
||||||
expect(result).toEqual([])
|
const result = coalesceServerEvents([first, other, last])
|
||||||
|
|
||||||
|
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("enqueueServerEvent", () => {
|
||||||
|
const partUpdated = (text: string) =>
|
||||||
|
({
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||||
|
},
|
||||||
|
}) as Event
|
||||||
|
|
||||||
|
test("preserves part updates across message remove and re-add barriers", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||||
|
enqueue({
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
info: {
|
||||||
|
id: "message",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"message.removed",
|
||||||
|
"message.updated",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves deltas after a replacement snapshot", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("a"))
|
||||||
|
enqueue(partUpdated("ab"))
|
||||||
|
enqueue({
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
const result = coalesceServerEvents(events)
|
||||||
|
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||||
|
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||||
|
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves updates after session deletion", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: "session", info: { id: "session" } },
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"session.deleted",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not coalesce edge-triggered session statuses", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (status: "retry" | "busy") =>
|
||||||
|
enqueueServerEvent(events, {
|
||||||
|
directory: "/repo",
|
||||||
|
payload: {
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||||
|
},
|
||||||
|
} as Event,
|
||||||
|
})
|
||||||
|
|
||||||
|
enqueue("retry")
|
||||||
|
enqueue("busy")
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,34 +17,59 @@ const isAbortError = (error: unknown) =>
|
||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
type QueuedServerEvent = { directory: string; payload: Event }
|
type QueuedServerEvent = { directory: string; payload: Event }
|
||||||
|
|
||||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
|
if (event.payload.type === "message.part.updated") {
|
||||||
|
const part = event.payload.properties.part
|
||||||
|
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
export function enqueueServerEvent(
|
||||||
|
queue: QueuedServerEvent[],
|
||||||
|
event: QueuedServerEvent,
|
||||||
|
) {
|
||||||
|
const key = coalescedKey(event)
|
||||||
|
const previous = queue[queue.length - 1]
|
||||||
|
if (key && previous && coalescedKey(previous) === key) {
|
||||||
|
queue[queue.length - 1] = event
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
queue.push(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
const output: QueuedServerEvent[] = []
|
const output: QueuedServerEvent[] = []
|
||||||
const deltas = new Map<string, number>()
|
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
if (stale && event.payload.type === "message.part.delta") {
|
|
||||||
const props = event.payload.properties
|
|
||||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
|
||||||
}
|
|
||||||
if (event.payload.type !== "message.part.delta") {
|
if (event.payload.type !== "message.part.delta") {
|
||||||
deltas.clear()
|
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const props = event.payload.properties
|
const props = event.payload.properties
|
||||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
const previous = output[output.length - 1]
|
||||||
const index = deltas.get(id)
|
if (
|
||||||
const existing = index === undefined ? undefined : output[index]
|
!previous ||
|
||||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
previous.payload.type !== "message.part.delta" ||
|
||||||
deltas.set(id, output.length)
|
previous.directory !== event.directory ||
|
||||||
|
previous.payload.properties.messageID !== props.messageID ||
|
||||||
|
previous.payload.properties.partID !== props.partID ||
|
||||||
|
previous.payload.properties.field !== props.field
|
||||||
|
) {
|
||||||
output.push({
|
output.push({
|
||||||
directory: event.directory,
|
directory: event.directory,
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
payload: { ...event.payload, properties: { ...props } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing.payload.properties.delta += props.delta
|
output[output.length - 1] = {
|
||||||
|
directory: event.directory,
|
||||||
|
payload: {
|
||||||
|
...event.payload,
|
||||||
|
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
@ -85,20 +110,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
|
|
||||||
let queue: Queued[] = []
|
let queue: Queued[] = []
|
||||||
let buffer: Queued[] = []
|
let buffer: Queued[] = []
|
||||||
const coalesced = new Map<string, number>()
|
|
||||||
const staleDeltas = new Set<string>()
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
const key = (directory: string, payload: Event) => {
|
|
||||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
|
||||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
|
|
@ -106,15 +120,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
if (queue.length === 0) return
|
if (queue.length === 0) return
|
||||||
|
|
||||||
const events = queue
|
const events = queue
|
||||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
|
||||||
queue = buffer
|
queue = buffer
|
||||||
buffer = events
|
buffer = events
|
||||||
queue.length = 0
|
queue.length = 0
|
||||||
coalesced.clear()
|
|
||||||
staleDeltas.clear()
|
|
||||||
|
|
||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events, skip)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||||
})
|
})
|
||||||
|
|
@ -184,29 +195,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
if (event.payload.type !== "sync") {
|
||||||
if (event.payload.type === "sync") {
|
const directory = event.directory ?? "global"
|
||||||
continue
|
const payload = event.payload as Event
|
||||||
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = event.payload as Event
|
|
||||||
|
|
||||||
const k = key(directory, payload)
|
|
||||||
if (k) {
|
|
||||||
const i = coalesced.get(k)
|
|
||||||
if (i !== undefined) {
|
|
||||||
queue[i] = { directory, payload }
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coalesced.set(k, queue.length)
|
|
||||||
}
|
|
||||||
queue.push({ directory, payload })
|
|
||||||
schedule()
|
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
await wait(0)
|
await wait(0)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -18,44 +18,70 @@ import { rootSession } from "@/utils/session-route"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 2
|
const initialMessagePageSize = 2
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: Message
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
|
confirmedParts?: Part[]
|
||||||
|
confirmedMessage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
type MessagePage = {
|
||||||
if (!parts) return want.length === 0
|
session: Message[]
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
part: { id: string; part: Part[] }[]
|
||||||
|
cursor?: string
|
||||||
|
complete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
|
type MessageLoadState = {
|
||||||
|
touchedMessages: Set<string>
|
||||||
|
removedMessages: Set<string>
|
||||||
|
retainedMessages: Set<string>
|
||||||
|
touchedParts: Map<string, Set<string>>
|
||||||
|
deltaParts: Map<string, Set<string>>
|
||||||
|
carriedDeltaParts: Map<string, Set<string>>
|
||||||
|
removedParts: Map<string, Set<string>>
|
||||||
|
optimisticParts: Map<string, Set<string>>
|
||||||
|
orphanParents: Set<string>
|
||||||
|
clearedMessageParts: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeOptimisticPage(
|
function mergeOptimisticPage(
|
||||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
page: MessagePage,
|
||||||
items: OptimisticItem[],
|
items: OptimisticItem[],
|
||||||
) {
|
) {
|
||||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||||
const confirmed: string[] = []
|
const observed: { messageID: string; parts: Part[] }[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||||
if (!result.found) session.splice(result.index, 0, item.message)
|
if (!result.found) session.splice(result.index, 0, item.message)
|
||||||
const current = part.get(item.message.id)
|
const current = part.get(item.message.id)
|
||||||
if (result.found && hasParts(current, item.parts)) {
|
const confirmed = result.found
|
||||||
confirmed.push(item.message.id)
|
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||||
continue
|
: []
|
||||||
}
|
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
part.set(
|
||||||
|
item.message.id,
|
||||||
|
merge(
|
||||||
|
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||||
|
item.parts.filter((part) => !confirmed.includes(part)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
session,
|
session,
|
||||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||||
confirmed,
|
observed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +101,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSession(client: OpencodeClient) {
|
function reconcileFetched<T extends { id: string }>(
|
||||||
|
fetched: T[],
|
||||||
|
current: readonly T[],
|
||||||
|
options: {
|
||||||
|
touched?: ReadonlySet<string>
|
||||||
|
retained?: ReadonlySet<string>
|
||||||
|
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||||
|
const live = new Map(current.map((item) => [item.id, item]))
|
||||||
|
if (options.preserveUnfetched) {
|
||||||
|
for (const item of current) {
|
||||||
|
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||||
|
result.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of options.retained ?? emptyIDs) {
|
||||||
|
if (result.has(id)) continue
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
}
|
||||||
|
// Events observed while the request is pending are the freshest client state for those identities.
|
||||||
|
for (const id of options.touched ?? emptyIDs) {
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
if (!item) result.delete(id)
|
||||||
|
}
|
||||||
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
|
|
@ -95,10 +152,29 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
|
const messageLoads = new Map<string, MessageLoadState>()
|
||||||
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
|
const removedMessages = new Map<string, Set<string>>()
|
||||||
|
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||||
|
const deleteMessageParts = (cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> }, messageID: string) => {
|
||||||
|
for (const part of cache.part[messageID] ?? []) {
|
||||||
|
delete cache.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
delete cache.part[messageID]
|
||||||
|
}
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const infoSeen = new Set<string>()
|
const infoSeen = new Set<string>()
|
||||||
const pinned = new Map<string, number>()
|
const pinned = new Map<string, number>()
|
||||||
const generations = new Map<string, number>()
|
const generations = new Map<string, object>()
|
||||||
|
const generation = (sessionID: string) => {
|
||||||
|
const current = generations.get(sessionID)
|
||||||
|
if (current) return current
|
||||||
|
const created = {}
|
||||||
|
generations.set(sessionID, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number | undefined>,
|
limit: {} as Record<string, number | undefined>,
|
||||||
cursor: {} as Record<string, string | undefined>,
|
cursor: {} as Record<string, string | undefined>,
|
||||||
|
|
@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
const preserve = new Set([
|
const preserve = new Set([
|
||||||
...pinned.keys(),
|
...pinned.keys(),
|
||||||
...requests.keys(),
|
...requests.keys(),
|
||||||
|
...inflight.keys(),
|
||||||
|
...inflightDiff.keys(),
|
||||||
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
|
|
@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||||
}
|
}
|
||||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||||
|
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||||
setData(
|
setData(
|
||||||
"info",
|
"info",
|
||||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||||
|
|
@ -151,20 +233,29 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = client.session.get({ sessionID }).then((result) => {
|
||||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
if (generations.get(sessionID) !== active) return result.data
|
||||||
return remember(result.data)
|
return remember(result.data)
|
||||||
})
|
})
|
||||||
requests.set(sessionID, request)
|
requests.set(sessionID, request)
|
||||||
|
const cleanup = () => {
|
||||||
|
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||||
|
if (
|
||||||
|
generations.get(sessionID) === active &&
|
||||||
|
!data.info[sessionID] &&
|
||||||
|
!requests.has(sessionID) &&
|
||||||
|
!messageLoads.has(sessionID) &&
|
||||||
|
!inflight.has(sessionID) &&
|
||||||
|
!inflightDiff.has(sessionID) &&
|
||||||
|
!inflightTodo.has(sessionID)
|
||||||
|
)
|
||||||
|
generations.delete(sessionID)
|
||||||
|
}
|
||||||
void request.then(
|
void request.then(
|
||||||
() => {
|
cleanup,
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
cleanup,
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
@ -195,15 +286,120 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
if (items.size === 0) optimistic.delete(sessionID)
|
if (items.size === 0) optimistic.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((part) => part.id !== partID)
|
||||||
|
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||||
|
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const load = messageLoads.get(sessionID)
|
||||||
|
if (!load) return
|
||||||
|
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found) load.retainedMessages.add(messageID)
|
||||||
|
const parts = load.touchedParts.get(messageID)
|
||||||
|
if (parts) {
|
||||||
|
parts.add(partID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load.touchedParts.set(messageID, new Set([partID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||||
|
load.touchedMessages.clear()
|
||||||
|
load.retainedMessages.clear()
|
||||||
|
load.touchedParts.clear()
|
||||||
|
load.carriedDeltaParts.clear()
|
||||||
|
load.clearedMessageParts.clear()
|
||||||
|
for (const messageID of load.removedMessages) {
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.deltaParts) {
|
||||||
|
load.touchedParts.set(messageID, new Set(parts))
|
||||||
|
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.removedParts) {
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.optimisticParts) {
|
||||||
|
load.removedMessages.delete(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const evict = (sessionIDs: string[]) => {
|
const evict = (sessionIDs: string[]) => {
|
||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
|
const evicted = new Set(sessionIDs)
|
||||||
|
for (const [partID, item] of deltaBases) {
|
||||||
|
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||||
|
}
|
||||||
sessionIDs.forEach((sessionID) => {
|
sessionIDs.forEach((sessionID) => {
|
||||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
generations.delete(sessionID)
|
||||||
clearOptimistic(sessionID)
|
clearOptimistic(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
inflight.delete(sessionID)
|
inflight.delete(sessionID)
|
||||||
inflightDiff.delete(sessionID)
|
inflightDiff.delete(sessionID)
|
||||||
inflightTodo.delete(sessionID)
|
inflightTodo.delete(sessionID)
|
||||||
|
messageLoads.delete(sessionID)
|
||||||
|
pendingParts.delete(sessionID)
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
|
removedMessages.delete(sessionID)
|
||||||
})
|
})
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -230,6 +426,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightDiff.keys(),
|
...inflightDiff.keys(),
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
|
|
@ -247,8 +444,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
|
onAttempt?.()
|
||||||
|
return client.session.messages({ sessionID, limit, before })
|
||||||
|
})
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
|
@ -261,30 +461,164 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
|
const messageIDs = new Set(messages.map((message) => message.id))
|
||||||
|
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||||
|
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||||
|
setData(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceParts = (
|
||||||
|
sessionID: string,
|
||||||
|
items: MessagePage["part"],
|
||||||
|
messageIDs: Set<string>,
|
||||||
|
load?: MessageLoadState,
|
||||||
|
) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (!messageIDs.has(item.id)) continue
|
||||||
|
const fetched = load?.clearedMessageParts.has(item.id)
|
||||||
|
? []
|
||||||
|
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||||
|
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||||
|
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||||
|
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||||
|
for (const part of fetched) {
|
||||||
|
const accumulated = data.part_text_accum_delta[part.id]
|
||||||
|
const base = deltaBases.get(part.id)?.base
|
||||||
|
const preserveDelta =
|
||||||
|
base !== undefined &&
|
||||||
|
accumulated !== undefined &&
|
||||||
|
"text" in part &&
|
||||||
|
typeof part.text === "string" &&
|
||||||
|
part.text.startsWith(base) &&
|
||||||
|
accumulated.startsWith(part.text) &&
|
||||||
|
accumulated !== part.text
|
||||||
|
if (preserveDelta) touched.add(part.id)
|
||||||
|
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||||
|
}
|
||||||
|
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||||
|
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||||
|
}
|
||||||
|
const parts = reconcileFetched(
|
||||||
|
fetched,
|
||||||
|
data.part[item.id] ?? [],
|
||||||
|
{ touched },
|
||||||
|
)
|
||||||
|
if (!parts.length) {
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const partIDs = new Set(parts.map((part) => part.id))
|
||||||
|
setData(
|
||||||
|
"part_text_accum_delta",
|
||||||
|
produce((draft) => {
|
||||||
|
for (const part of data.part[item.id] ?? []) {
|
||||||
|
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMessagePage = (
|
||||||
|
sessionID: string,
|
||||||
|
page: MessagePage,
|
||||||
|
load: MessageLoadState | undefined,
|
||||||
|
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||||
|
cleanupOrphans: boolean,
|
||||||
|
) => {
|
||||||
|
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||||
|
merged.observed.forEach((item) => {
|
||||||
|
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||||
|
})
|
||||||
|
const touchedMessages = new Set([
|
||||||
|
...(load?.touchedMessages ?? []),
|
||||||
|
...(removedMessages.get(sessionID) ?? []),
|
||||||
|
])
|
||||||
|
const messages = reconcileFetched(
|
||||||
|
merged.session,
|
||||||
|
data.message[sessionID] ?? [],
|
||||||
|
{
|
||||||
|
touched: touchedMessages,
|
||||||
|
retained: load?.retainedMessages,
|
||||||
|
preserveUnfetched,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
batch(() => {
|
||||||
|
const messageIDs = replaceMessages(sessionID, messages)
|
||||||
|
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||||
|
const orphans = orphanParts.get(sessionID)
|
||||||
|
if (cleanupOrphans && page.complete && orphans) {
|
||||||
|
for (const messageID of orphans) {
|
||||||
|
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
}
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
setMeta("limit", sessionID, messages.length)
|
||||||
|
setMeta("cursor", sessionID, merged.cursor)
|
||||||
|
setMeta("complete", sessionID, merged.complete)
|
||||||
|
setMeta("at", sessionID, Date.now())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||||
if (meta.loading[sessionID]) return
|
if (meta.loading[sessionID]) return
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
|
const load: MessageLoadState = {
|
||||||
|
touchedMessages: new Set(),
|
||||||
|
removedMessages: new Set(),
|
||||||
|
retainedMessages: new Set(),
|
||||||
|
touchedParts: new Map(),
|
||||||
|
deltaParts: new Map(),
|
||||||
|
carriedDeltaParts: new Map(),
|
||||||
|
removedParts: new Map(),
|
||||||
|
optimisticParts: new Map(),
|
||||||
|
orphanParents: new Set(),
|
||||||
|
clearedMessageParts: new Set(),
|
||||||
|
}
|
||||||
|
messageLoads.set(sessionID, load)
|
||||||
setMeta("loading", sessionID, true)
|
setMeta("loading", sessionID, true)
|
||||||
await fetchMessages(sessionID, limit, before)
|
let applied = false
|
||||||
|
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
const first = page.session.reduce<Message | undefined>(
|
||||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
undefined,
|
||||||
batch(() => {
|
)
|
||||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
const preserveUnfetched =
|
||||||
for (const item of next.part) {
|
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
applyMessagePage(
|
||||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
sessionID,
|
||||||
}
|
page,
|
||||||
setMeta("limit", sessionID, messages.length)
|
messageLoads.get(sessionID) === load ? load : undefined,
|
||||||
setMeta("cursor", sessionID, next.cursor)
|
preserveUnfetched,
|
||||||
setMeta("complete", sessionID, next.complete)
|
mode !== "prepend",
|
||||||
setMeta("at", sessionID, Date.now())
|
)
|
||||||
})
|
applied = true
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||||
|
for (const messageID of load.orphanParents) {
|
||||||
|
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
orphanParts.get(sessionID)?.delete(messageID)
|
||||||
|
}
|
||||||
|
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||||
|
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,7 +673,13 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
const eventID = eventSessionID(event)
|
const eventID = eventSessionID(event)
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
touch(eventID)
|
touch(eventID)
|
||||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
if (
|
||||||
|
!data.info[eventID] &&
|
||||||
|
event.type !== "session.created" &&
|
||||||
|
event.type !== "session.updated" &&
|
||||||
|
event.type !== "session.deleted"
|
||||||
|
)
|
||||||
|
void resolve(eventID).catch(() => {})
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
|
|
@ -378,6 +718,21 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||||
|
const load = messageLoads.get(info.sessionID)
|
||||||
|
load?.touchedMessages.add(info.id)
|
||||||
|
load?.removedMessages.delete(info.id)
|
||||||
|
const items = optimistic.get(info.sessionID)
|
||||||
|
const item = items?.get(info.id)
|
||||||
|
if (items && item) {
|
||||||
|
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||||
|
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
const orphans = orphanParts.get(info.sessionID)
|
||||||
|
orphans?.delete(info.id)
|
||||||
|
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||||
|
removedMessagesForSession?.delete(info.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||||
const messages = data.message[info.sessionID]
|
const messages = data.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
setData("message", info.sessionID, [info])
|
setData("message", info.sessionID, [info])
|
||||||
|
|
@ -395,6 +750,20 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
const props = event.properties as { sessionID: string; messageID: string }
|
const props = event.properties as { sessionID: string; messageID: string }
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
load?.touchedMessages.add(props.messageID)
|
||||||
|
load?.removedMessages.add(props.messageID)
|
||||||
|
load?.clearedMessageParts.add(props.messageID)
|
||||||
|
load?.deltaParts.delete(props.messageID)
|
||||||
|
load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
load?.removedParts.delete(props.messageID)
|
||||||
|
load?.optimisticParts.delete(props.messageID)
|
||||||
|
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||||
|
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||||
|
removedMessagesForSession.add(props.messageID)
|
||||||
|
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||||
|
clearOptimistic(props.sessionID, props.messageID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
|
|
@ -402,8 +771,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (result.found) messages.splice(result.index, 1)
|
||||||
}
|
}
|
||||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
deleteMessageParts(draft, props.messageID)
|
||||||
delete draft.part[props.messageID]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -411,6 +779,40 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const part = (event.properties as { part: Part }).part
|
const part = (event.properties as { part: Part }).part
|
||||||
if (SKIP_PARTS.has(part.type)) return
|
if (SKIP_PARTS.has(part.type)) return
|
||||||
|
const messages = data.message[part.sessionID]
|
||||||
|
const load = messageLoads.get(part.sessionID)
|
||||||
|
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||||
|
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||||
|
if (
|
||||||
|
missing &&
|
||||||
|
(!load || load.clearedMessageParts.has(part.messageID) || removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (missing) {
|
||||||
|
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||||
|
orphans.add(part.messageID)
|
||||||
|
orphanParts.set(part.sessionID, orphans)
|
||||||
|
load?.orphanParents.add(part.messageID)
|
||||||
|
}
|
||||||
|
const deltas = load?.deltaParts.get(part.messageID)
|
||||||
|
deltas?.delete(part.id)
|
||||||
|
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||||
|
carried?.delete(part.id)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||||
|
const removed = load?.removedParts.get(part.messageID)
|
||||||
|
removed?.delete(part.id)
|
||||||
|
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||||
|
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||||
|
pending?.delete(part.id)
|
||||||
|
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||||
|
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||||
|
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||||
|
optimistic?.delete(part.id)
|
||||||
|
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||||
|
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
produce((draft) => void delete draft[part.id]),
|
produce((draft) => void delete draft[part.id]),
|
||||||
|
|
@ -431,10 +833,34 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
const props = event.properties as { messageID: string; partID: string }
|
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||||
|
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||||
|
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||||
|
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
pending.set(props.messageID, parts)
|
||||||
|
pendingParts.set(props.sessionID, pending)
|
||||||
|
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||||
|
deltas?.delete(props.partID)
|
||||||
|
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.removedParts.set(props.messageID, parts)
|
||||||
|
const optimistic = load.optimisticParts.get(props.messageID)
|
||||||
|
optimistic?.delete(props.partID)
|
||||||
|
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||||
|
}
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft.part_text_accum_delta[props.partID]
|
delete draft.part_text_accum_delta[props.partID]
|
||||||
|
deltaBases.delete(props.partID)
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
|
|
@ -445,13 +871,31 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.delta": {
|
case "message.part.delta": {
|
||||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
const props = event.properties as {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
partID: string
|
||||||
|
field: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
const parts = data.part[props.messageID]
|
const parts = data.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
if (!result.found) return
|
if (!result.found) return
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.deltaParts.set(props.messageID, parts)
|
||||||
|
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||||
|
}
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
const field = props.field as keyof (typeof parts)[number]
|
||||||
const current = parts[result.index]?.[field]
|
const current = parts[result.index]?.[field]
|
||||||
|
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||||
|
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
props.partID,
|
props.partID,
|
||||||
|
|
@ -559,32 +1003,67 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||||
|
const parts = input.parts.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)).sort((a, b) => cmp(a.id, b.id))
|
||||||
|
const load = messageLoads.get(input.sessionID)
|
||||||
|
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||||
|
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||||
|
parts.forEach((part) => touched.add(part.id))
|
||||||
|
load.touchedParts.set(input.message.id, touched)
|
||||||
|
}
|
||||||
|
if (load) {
|
||||||
|
load.removedMessages.delete(input.message.id)
|
||||||
|
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||||
|
}
|
||||||
const items = optimistic.get(input.sessionID)
|
const items = optimistic.get(input.sessionID)
|
||||||
if (items) items.set(input.message.id, input)
|
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
removedMessagesForSession?.delete(input.message.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||||
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
|
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||||
setData(
|
setData(
|
||||||
"part",
|
"part_text_accum_delta",
|
||||||
input.message.id,
|
produce((draft) => {
|
||||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
setData("part", input.message.id, parts)
|
||||||
},
|
},
|
||||||
remove(input: { sessionID: string; messageID: string }) {
|
remove(input: { sessionID: string; messageID: string }) {
|
||||||
|
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||||
|
if (!item) return
|
||||||
|
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||||
clearOptimistic(input.sessionID, input.messageID)
|
clearOptimistic(input.sessionID, input.messageID)
|
||||||
|
if (item.confirmedMessage) {
|
||||||
|
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||||
|
setData(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const part of item.parts) {
|
||||||
|
delete draft.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
const parts = draft.part[input.messageID]
|
||||||
|
if (!parts) return
|
||||||
|
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||||
|
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
"part",
|
|
||||||
produce((draft) => void delete draft[input.messageID]),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff(sessionID: string, options?: { force?: boolean }) {
|
diff(sessionID: string, options?: { force?: boolean }) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightDiff, sessionID, () => {
|
return runInflight(inflightDiff, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -593,9 +1072,9 @@ export function createServerSession(client: OpencodeClient) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue