sync.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import { batch, createMemo } from "solid-js"
  2. import { createStore, produce, reconcile } from "solid-js/store"
  3. import { Binary } from "@opencode-ai/core/util/binary"
  4. import { retry } from "@opencode-ai/core/util/retry"
  5. import { createSimpleContext } from "@opencode-ai/ui/context"
  6. import {
  7. clearSessionPrefetch,
  8. getSessionPrefetch,
  9. getSessionPrefetchPromise,
  10. setSessionPrefetch,
  11. } from "./global-sync/session-prefetch"
  12. import { useGlobalSync } from "./global-sync"
  13. import { useSDK } from "./sdk"
  14. import type { Message, Part } from "@opencode-ai/sdk/v2/client"
  15. import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
  16. import { diffs as list, message as clean } from "@/utils/diffs"
  17. const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
  18. function sortParts(parts: Part[]) {
  19. return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
  20. }
  21. function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
  22. const pending = map.get(key)
  23. if (pending) return pending
  24. const promise = task().finally(() => {
  25. map.delete(key)
  26. })
  27. map.set(key, promise)
  28. return promise
  29. }
  30. const keyFor = (directory: string, id: string) => `${directory}\n${id}`
  31. const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
  32. function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
  33. const map = new Map(a.map((item) => [item.id, item] as const))
  34. for (const item of b) map.set(item.id, item)
  35. return [...map.values()].sort((x, y) => cmp(x.id, y.id))
  36. }
  37. type OptimisticStore = {
  38. message: Record<string, Message[] | undefined>
  39. part: Record<string, Part[] | undefined>
  40. }
  41. type OptimisticAddInput = {
  42. sessionID: string
  43. message: Message
  44. parts: Part[]
  45. }
  46. type OptimisticRemoveInput = {
  47. sessionID: string
  48. messageID: string
  49. }
  50. type OptimisticItem = {
  51. message: Message
  52. parts: Part[]
  53. }
  54. type MessagePage = {
  55. session: Message[]
  56. part: { id: string; part: Part[] }[]
  57. cursor?: string
  58. complete: boolean
  59. }
  60. const hasParts = (parts: Part[] | undefined, want: Part[]) => {
  61. if (!parts) return want.length === 0
  62. return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
  63. }
  64. const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
  65. if (!parts) return sortParts(want)
  66. const next = [...parts]
  67. let changed = false
  68. for (const part of want) {
  69. const result = Binary.search(next, part.id, (item) => item.id)
  70. if (result.found) continue
  71. next.splice(result.index, 0, part)
  72. changed = true
  73. }
  74. if (!changed) return parts
  75. return next
  76. }
  77. export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
  78. if (items.length === 0) return { ...page, confirmed: [] as string[] }
  79. const session = [...page.session]
  80. const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
  81. const confirmed: string[] = []
  82. for (const item of items) {
  83. const result = Binary.search(session, item.message.id, (message) => message.id)
  84. const found = result.found
  85. if (!found) session.splice(result.index, 0, item.message)
  86. const current = part.get(item.message.id)
  87. if (found && hasParts(current, item.parts)) {
  88. confirmed.push(item.message.id)
  89. continue
  90. }
  91. part.set(item.message.id, mergeParts(current, item.parts))
  92. }
  93. return {
  94. cursor: page.cursor,
  95. complete: page.complete,
  96. session,
  97. part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
  98. confirmed,
  99. }
  100. }
  101. export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
  102. const messages = draft.message[input.sessionID]
  103. if (messages) {
  104. const result = Binary.search(messages, input.message.id, (m) => m.id)
  105. messages.splice(result.index, 0, input.message)
  106. } else {
  107. draft.message[input.sessionID] = [input.message]
  108. }
  109. draft.part[input.message.id] = sortParts(input.parts)
  110. }
  111. export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
  112. const messages = draft.message[input.sessionID]
  113. if (messages) {
  114. const result = Binary.search(messages, input.messageID, (m) => m.id)
  115. if (result.found) messages.splice(result.index, 1)
  116. }
  117. delete draft.part[input.messageID]
  118. }
  119. function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
  120. setStore("message", input.sessionID, (messages: Message[] | undefined) => {
  121. if (!messages) return [input.message]
  122. const result = Binary.search(messages, input.message.id, (m) => m.id)
  123. const next = [...messages]
  124. next.splice(result.index, 0, input.message)
  125. return next
  126. })
  127. setStore("part", input.message.id, sortParts(input.parts))
  128. }
  129. function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
  130. setStore("message", input.sessionID, (messages: Message[] | undefined) => {
  131. if (!messages) return messages
  132. const result = Binary.search(messages, input.messageID, (m) => m.id)
  133. if (!result.found) return messages
  134. const next = [...messages]
  135. next.splice(result.index, 1)
  136. return next
  137. })
  138. setStore("part", (part: Record<string, Part[] | undefined>) => {
  139. if (!(input.messageID in part)) return part
  140. const next = { ...part }
  141. delete next[input.messageID]
  142. return next
  143. })
  144. }
  145. export const { use: useSync, provider: SyncProvider } = createSimpleContext({
  146. name: "Sync",
  147. init: () => {
  148. const globalSync = useGlobalSync()
  149. const sdk = useSDK()
  150. type Child = ReturnType<(typeof globalSync)["child"]>
  151. type Setter = Child[1]
  152. const current = createMemo(() => globalSync.child(sdk.directory))
  153. const target = (directory?: string) => {
  154. if (!directory || directory === sdk.directory) return current()
  155. return globalSync.child(directory)
  156. }
  157. const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
  158. const initialMessagePageSize = 80
  159. const historyMessagePageSize = 200
  160. const inflight = new Map<string, Promise<void>>()
  161. const inflightDiff = new Map<string, Promise<void>>()
  162. const inflightTodo = new Map<string, Promise<void>>()
  163. const optimistic = new Map<string, Map<string, OptimisticItem>>()
  164. const maxDirs = 30
  165. const seen = new Map<string, Set<string>>()
  166. const [meta, setMeta] = createStore({
  167. limit: {} as Record<string, number>,
  168. cursor: {} as Record<string, string | undefined>,
  169. complete: {} as Record<string, boolean>,
  170. loading: {} as Record<string, boolean>,
  171. })
  172. const getSession = (sessionID: string) => {
  173. const store = current()[0]
  174. const match = Binary.search(store.session, sessionID, (s) => s.id)
  175. if (match.found) return store.session[match.index]
  176. return undefined
  177. }
  178. const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
  179. const key = keyFor(directory, sessionID)
  180. const list = optimistic.get(key)
  181. if (list) {
  182. list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
  183. return
  184. }
  185. optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
  186. }
  187. const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
  188. const key = keyFor(directory, sessionID)
  189. if (!messageID) {
  190. optimistic.delete(key)
  191. return
  192. }
  193. const list = optimistic.get(key)
  194. if (!list) return
  195. list.delete(messageID)
  196. if (list.size === 0) optimistic.delete(key)
  197. }
  198. const getOptimistic = (directory: string, sessionID: string) => [
  199. ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
  200. ]
  201. const seenFor = (directory: string) => {
  202. const existing = seen.get(directory)
  203. if (existing) {
  204. seen.delete(directory)
  205. seen.set(directory, existing)
  206. return existing
  207. }
  208. const created = new Set<string>()
  209. seen.set(directory, created)
  210. while (seen.size > maxDirs) {
  211. const first = seen.keys().next().value
  212. if (!first) break
  213. const stale = [...(seen.get(first) ?? [])]
  214. seen.delete(first)
  215. const [, setStore] = globalSync.child(first, { bootstrap: false })
  216. evict(first, setStore, stale)
  217. }
  218. return created
  219. }
  220. const clearMeta = (directory: string, sessionIDs: string[]) => {
  221. if (sessionIDs.length === 0) return
  222. for (const sessionID of sessionIDs) {
  223. clearOptimistic(directory, sessionID)
  224. }
  225. setMeta(
  226. produce((draft) => {
  227. for (const sessionID of sessionIDs) {
  228. const key = keyFor(directory, sessionID)
  229. delete draft.limit[key]
  230. delete draft.cursor[key]
  231. delete draft.complete[key]
  232. delete draft.loading[key]
  233. }
  234. }),
  235. )
  236. }
  237. const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
  238. if (sessionIDs.length === 0) return
  239. clearSessionPrefetch(directory, sessionIDs)
  240. for (const sessionID of sessionIDs) {
  241. globalSync.todo.set(sessionID, undefined)
  242. }
  243. setStore(
  244. produce((draft) => {
  245. dropSessionCaches(draft, sessionIDs)
  246. }),
  247. )
  248. clearMeta(directory, sessionIDs)
  249. }
  250. const touch = (directory: string, setStore: Setter, sessionID: string) => {
  251. const stale = pickSessionCacheEvictions({
  252. seen: seenFor(directory),
  253. keep: sessionID,
  254. limit: SESSION_CACHE_LIMIT,
  255. })
  256. evict(directory, setStore, stale)
  257. }
  258. const fetchMessages = async (input: {
  259. client: typeof sdk.client
  260. sessionID: string
  261. limit: number
  262. before?: string
  263. }) => {
  264. const messages = await retry(() =>
  265. input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
  266. )
  267. const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
  268. const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
  269. const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
  270. const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
  271. return {
  272. session,
  273. part,
  274. cursor,
  275. complete: !cursor,
  276. }
  277. }
  278. const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
  279. const loadMessages = async (input: {
  280. directory: string
  281. client: typeof sdk.client
  282. setStore: Setter
  283. sessionID: string
  284. limit: number
  285. before?: string
  286. mode?: "replace" | "prepend"
  287. }) => {
  288. const key = keyFor(input.directory, input.sessionID)
  289. if (meta.loading[key]) return
  290. setMeta("loading", key, true)
  291. await fetchMessages(input)
  292. .then((page) => {
  293. if (!tracked(input.directory, input.sessionID)) return
  294. const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
  295. for (const messageID of next.confirmed) {
  296. clearOptimistic(input.directory, input.sessionID, messageID)
  297. }
  298. const [store] = globalSync.child(input.directory, { bootstrap: false })
  299. const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
  300. const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
  301. batch(() => {
  302. input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
  303. for (const p of next.part) {
  304. const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
  305. if (filtered.length) input.setStore("part", p.id, filtered)
  306. }
  307. setMeta("limit", key, message.length)
  308. setMeta("cursor", key, next.cursor)
  309. setMeta("complete", key, next.complete)
  310. setSessionPrefetch({
  311. directory: input.directory,
  312. sessionID: input.sessionID,
  313. limit: message.length,
  314. cursor: next.cursor,
  315. complete: next.complete,
  316. })
  317. })
  318. })
  319. .finally(() => {
  320. setMeta(
  321. produce((draft) => {
  322. if (!tracked(input.directory, input.sessionID)) {
  323. delete draft.loading[key]
  324. return
  325. }
  326. draft.loading[key] = false
  327. }),
  328. )
  329. })
  330. }
  331. return {
  332. get data() {
  333. return current()[0]
  334. },
  335. get set(): Setter {
  336. return current()[1]
  337. },
  338. get status() {
  339. return current()[0].status
  340. },
  341. get ready() {
  342. return current()[0].status !== "loading"
  343. },
  344. get project() {
  345. const store = current()[0]
  346. const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
  347. if (match.found) return globalSync.data.project[match.index]
  348. return undefined
  349. },
  350. session: {
  351. get: getSession,
  352. optimistic: {
  353. add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
  354. const directory = input.directory ?? sdk.directory
  355. const [, setStore] = target(input.directory)
  356. setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
  357. setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
  358. },
  359. remove(input: { directory?: string; sessionID: string; messageID: string }) {
  360. const directory = input.directory ?? sdk.directory
  361. const [, setStore] = target(input.directory)
  362. clearOptimistic(directory, input.sessionID, input.messageID)
  363. setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
  364. },
  365. },
  366. addOptimisticMessage(input: {
  367. sessionID: string
  368. messageID: string
  369. parts: Part[]
  370. agent: string
  371. model: { providerID: string; modelID: string }
  372. variant?: string
  373. }) {
  374. const message: Message = {
  375. id: input.messageID,
  376. sessionID: input.sessionID,
  377. role: "user",
  378. time: { created: Date.now() },
  379. agent: input.agent,
  380. model: { ...input.model, variant: input.variant },
  381. }
  382. const [, setStore] = target()
  383. setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
  384. setOptimisticAdd(setStore as (...args: unknown[]) => void, {
  385. sessionID: input.sessionID,
  386. message,
  387. parts: input.parts,
  388. })
  389. },
  390. async sync(sessionID: string, opts?: { force?: boolean }) {
  391. const directory = sdk.directory
  392. const client = sdk.client
  393. const [store, setStore] = globalSync.child(directory)
  394. const key = keyFor(directory, sessionID)
  395. touch(directory, setStore, sessionID)
  396. const seeded = getSessionPrefetch(directory, sessionID)
  397. if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
  398. batch(() => {
  399. setMeta("limit", key, seeded.limit)
  400. setMeta("cursor", key, seeded.cursor)
  401. setMeta("complete", key, seeded.complete)
  402. setMeta("loading", key, false)
  403. })
  404. }
  405. return runInflight(inflight, key, async () => {
  406. const pending = getSessionPrefetchPromise(directory, sessionID)
  407. if (pending) {
  408. await pending
  409. const seeded = getSessionPrefetch(directory, sessionID)
  410. if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
  411. batch(() => {
  412. setMeta("limit", key, seeded.limit)
  413. setMeta("cursor", key, seeded.cursor)
  414. setMeta("complete", key, seeded.complete)
  415. setMeta("loading", key, false)
  416. })
  417. }
  418. }
  419. const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
  420. const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
  421. if (cached && hasSession && !opts?.force) return
  422. const limit = meta.limit[key] ?? initialMessagePageSize
  423. const sessionReq =
  424. hasSession && !opts?.force
  425. ? Promise.resolve()
  426. : retry(() => client.session.get({ sessionID })).then((session) => {
  427. if (!tracked(directory, sessionID)) return
  428. const data = session.data
  429. if (!data) return
  430. setStore(
  431. "session",
  432. produce((draft) => {
  433. const match = Binary.search(draft, sessionID, (s) => s.id)
  434. if (match.found) {
  435. draft[match.index] = data
  436. return
  437. }
  438. draft.splice(match.index, 0, data)
  439. }),
  440. )
  441. })
  442. const messagesReq =
  443. cached && !opts?.force
  444. ? Promise.resolve()
  445. : loadMessages({
  446. directory,
  447. client,
  448. setStore,
  449. sessionID,
  450. limit,
  451. })
  452. await Promise.all([sessionReq, messagesReq])
  453. })
  454. },
  455. async diff(sessionID: string, opts?: { force?: boolean }) {
  456. const directory = sdk.directory
  457. const client = sdk.client
  458. const [store, setStore] = globalSync.child(directory)
  459. touch(directory, setStore, sessionID)
  460. if (store.session_diff[sessionID] !== undefined && !opts?.force) return
  461. const key = keyFor(directory, sessionID)
  462. return runInflight(inflightDiff, key, () =>
  463. retry(() => client.session.diff({ sessionID })).then((diff) => {
  464. if (!tracked(directory, sessionID)) return
  465. setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
  466. }),
  467. )
  468. },
  469. async todo(sessionID: string, opts?: { force?: boolean }) {
  470. const directory = sdk.directory
  471. const client = sdk.client
  472. const [store, setStore] = globalSync.child(directory)
  473. touch(directory, setStore, sessionID)
  474. const existing = store.todo[sessionID]
  475. const cached = globalSync.data.session_todo[sessionID]
  476. if (existing !== undefined) {
  477. if (cached === undefined) {
  478. globalSync.todo.set(sessionID, existing)
  479. }
  480. if (!opts?.force) return
  481. }
  482. if (cached !== undefined) {
  483. setStore("todo", sessionID, reconcile(cached, { key: "id" }))
  484. }
  485. const key = keyFor(directory, sessionID)
  486. return runInflight(inflightTodo, key, () =>
  487. retry(() => client.session.todo({ sessionID })).then((todo) => {
  488. if (!tracked(directory, sessionID)) return
  489. const list = todo.data ?? []
  490. setStore("todo", sessionID, reconcile(list, { key: "id" }))
  491. globalSync.todo.set(sessionID, list)
  492. }),
  493. )
  494. },
  495. history: {
  496. more(sessionID: string) {
  497. const store = current()[0]
  498. const key = keyFor(sdk.directory, sessionID)
  499. if (store.message[sessionID] === undefined) return false
  500. if (meta.limit[key] === undefined) return false
  501. if (meta.complete[key]) return false
  502. return !!meta.cursor[key]
  503. },
  504. loading(sessionID: string) {
  505. const key = keyFor(sdk.directory, sessionID)
  506. return meta.loading[key] ?? false
  507. },
  508. async loadMore(sessionID: string, count?: number) {
  509. const directory = sdk.directory
  510. const client = sdk.client
  511. const [, setStore] = globalSync.child(directory)
  512. touch(directory, setStore, sessionID)
  513. const key = keyFor(directory, sessionID)
  514. const step = count ?? historyMessagePageSize
  515. if (meta.loading[key]) return
  516. if (meta.complete[key]) return
  517. const before = meta.cursor[key]
  518. if (!before) return
  519. await loadMessages({
  520. directory,
  521. client,
  522. setStore,
  523. sessionID,
  524. limit: step,
  525. before,
  526. mode: "prepend",
  527. })
  528. },
  529. },
  530. evict(sessionID: string, directory = sdk.directory) {
  531. const [, setStore] = globalSync.child(directory)
  532. seenFor(directory).delete(sessionID)
  533. evict(directory, setStore, [sessionID])
  534. },
  535. fetch: async (count = 10) => {
  536. const directory = sdk.directory
  537. const client = sdk.client
  538. const [store, setStore] = globalSync.child(directory)
  539. setStore("limit", (x) => x + count)
  540. await client.session.list().then((x) => {
  541. const sessions = (x.data ?? [])
  542. .filter((s) => !!s?.id)
  543. .sort((a, b) => cmp(a.id, b.id))
  544. .slice(0, store.limit)
  545. setStore("session", reconcile(sessions, { key: "id" }))
  546. })
  547. },
  548. more: createMemo(() => current()[0].session.length >= current()[0].limit),
  549. archive: async (sessionID: string) => {
  550. const directory = sdk.directory
  551. const client = sdk.client
  552. const [, setStore] = globalSync.child(directory)
  553. await client.session.update({ sessionID, time: { archived: Date.now() } })
  554. setStore(
  555. produce((draft) => {
  556. const match = Binary.search(draft.session, sessionID, (s) => s.id)
  557. if (match.found) draft.session.splice(match.index, 1)
  558. }),
  559. )
  560. },
  561. },
  562. absolute,
  563. get directory() {
  564. return current()[0].path.directory
  565. },
  566. }
  567. },
  568. })