server-sync.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
  2. import { showToast } from "@/utils/toast"
  3. import { getFilename } from "@opencode-ai/core/util/path"
  4. import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
  5. import { createStore, produce, reconcile } from "solid-js/store"
  6. import { useLanguage } from "@/context/language"
  7. import type { InitError } from "../pages/error"
  8. import { ServerSDK } from "./server-sdk"
  9. import {
  10. bootstrapDirectory,
  11. bootstrapGlobal,
  12. clearProviderRev,
  13. loadAgentsQuery,
  14. loadGlobalConfigQuery,
  15. loadPathQuery,
  16. loadProjectsQuery,
  17. loadProvidersQuery,
  18. } from "./global-sync/bootstrap"
  19. import { createChildStoreManager } from "./global-sync/child-store"
  20. import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
  21. import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
  22. import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
  23. import { trimSessions } from "./global-sync/session-trim"
  24. import type { ProjectMeta } from "./global-sync/types"
  25. import { SESSION_RECENT_LIMIT } from "./global-sync/types"
  26. import { formatServerError } from "@/utils/server-errors"
  27. import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
  28. import { createRefreshQueue } from "./global-sync/queue"
  29. import { directoryKey } from "./global-sync/utils"
  30. import { PathKey } from "@/utils/path-key"
  31. import { createDirSyncContext } from "./directory-sync"
  32. import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
  33. import { createRefCountMap } from "@/utils/refcount"
  34. import { useGlobal } from "./global"
  35. import { ServerConnection, useServer } from "./server"
  36. import { retry } from "@opencode-ai/core/util/retry"
  37. import type { ServerScope } from "@/utils/server-scope"
  38. import { persisted } from "@/utils/persist"
  39. import { toggleMcp } from "./global-sync/mcp"
  40. type GlobalStore = {
  41. ready: boolean
  42. error?: InitError
  43. path: Path
  44. project: Project[]
  45. session_todo: {
  46. [sessionID: string]: Todo[]
  47. }
  48. provider: NormalizedProviderListResponse
  49. provider_auth: ProviderAuthResponse
  50. config: Config
  51. reload: undefined | "pending" | "complete"
  52. }
  53. export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
  54. queryOptions({
  55. queryKey: [scope, directory, "mcp"] as const,
  56. queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
  57. })
  58. export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
  59. queryOptions({
  60. queryKey: [scope, directory, "lsp"] as const,
  61. queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
  62. })
  63. function makeQueryOptionsApi(
  64. scope: ServerScope,
  65. serverSDK: () => OpencodeClient,
  66. sdkFor: (dir: PathKey) => OpencodeClient,
  67. ) {
  68. return {
  69. globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
  70. projects: () => loadProjectsQuery(scope, serverSDK()),
  71. providers: (directory: PathKey | null) =>
  72. loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
  73. path: (directory: PathKey | null) =>
  74. loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
  75. agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
  76. mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
  77. lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
  78. sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
  79. }
  80. }
  81. export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
  82. export function createServerSyncContextInner(serverSDK: ServerSDK) {
  83. const language = useLanguage()
  84. const owner = getOwner()
  85. if (!owner) throw new Error("ServerSync must be created within owner")
  86. const sdkCache = new Map<string, OpencodeClient>()
  87. const booting = new Map<string, Promise<void>>()
  88. const sessionLoads = new Map<string, Promise<void>>()
  89. const sessionMeta = new Map<string, { limit: number }>()
  90. const sdkFor = (directory: string) => {
  91. const key = directoryKey(directory)
  92. const cached = sdkCache.get(key)
  93. if (cached) return cached
  94. const sdk = serverSDK.createClient({
  95. directory,
  96. throwOnError: true,
  97. })
  98. sdkCache.set(key, sdk)
  99. return sdk
  100. }
  101. const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
  102. const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
  103. queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
  104. }))
  105. const [globalStore, setGlobalStore] = createStore<GlobalStore>({
  106. get ready() {
  107. return !bootstrap.isPending
  108. },
  109. project: [],
  110. session_todo: {},
  111. provider_auth: {},
  112. get path() {
  113. const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
  114. if (pathQuery.isLoading) return EMPTY
  115. return pathQuery.data ?? EMPTY
  116. },
  117. get provider() {
  118. const EMPTY = { all: new Map(), connected: [], default: {} }
  119. if (providerQuery.isLoading) return EMPTY
  120. return providerQuery.data ?? EMPTY
  121. },
  122. get config() {
  123. if (configQuery.isLoading) return {}
  124. return configQuery.data ?? {}
  125. },
  126. get reload() {
  127. return updateConfigMutation.isPending ? "pending" : undefined
  128. },
  129. })
  130. const queryClient = useQueryClient()
  131. let bootedAt = 0
  132. let bootingRoot = false
  133. let eventFrame: number | undefined
  134. let eventTimer: ReturnType<typeof setTimeout> | undefined
  135. onCleanup(() => {
  136. if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
  137. if (eventTimer !== undefined) clearTimeout(eventTimer)
  138. })
  139. const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
  140. setGlobalStore("project", next)
  141. }
  142. const setBootStore = ((...input: unknown[]) => {
  143. if (input[0] === "project" && Array.isArray(input[1])) {
  144. setProjects(input[1] as Project[])
  145. return input[1]
  146. }
  147. return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
  148. }) as typeof setGlobalStore
  149. const bootstrap = useQuery(() => ({
  150. queryKey: [serverSDK.scope, "bootstrap"],
  151. queryFn: async () => {
  152. await bootstrapGlobal({
  153. serverSDK: serverSDK.client,
  154. scope: serverSDK.scope,
  155. requestFailedTitle: language.t("common.requestFailed"),
  156. translate: language.t,
  157. formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
  158. setGlobalStore: setBootStore,
  159. queryClient,
  160. })
  161. bootedAt = Date.now()
  162. return bootedAt
  163. },
  164. }))
  165. const set = ((...input: unknown[]) => {
  166. if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
  167. setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
  168. return input[1]
  169. }
  170. return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
  171. }) as typeof setGlobalStore
  172. const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
  173. if (!sessionID) return
  174. if (!todos) {
  175. setGlobalStore(
  176. "session_todo",
  177. produce((draft) => {
  178. delete draft[sessionID]
  179. }),
  180. )
  181. return
  182. }
  183. setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
  184. }
  185. const paused = () => untrack(() => globalStore.reload) !== undefined
  186. const queue = createRefreshQueue({
  187. paused,
  188. key: directoryKey,
  189. bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
  190. bootstrapInstance,
  191. })
  192. const children = createChildStoreManager({
  193. owner,
  194. scope: serverSDK.scope,
  195. persist: persisted,
  196. isBooting: (directory) => booting.has(directory),
  197. isLoadingSessions: (directory) => sessionLoads.has(directory),
  198. onBootstrap: (directory) => {
  199. void bootstrapInstance(directory)
  200. },
  201. onMcp: (directory, setStore) => {
  202. void retry(() =>
  203. sdkFor(directory)
  204. .command.list()
  205. .then((x) => setStore("command", x.data ?? [])),
  206. ).catch((err) => {
  207. showToast({
  208. variant: "error",
  209. title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
  210. description: formatServerError(err, language.t),
  211. })
  212. })
  213. },
  214. onDispose: (directory) => {
  215. const key = directoryKey(directory)
  216. queue.clear(key)
  217. sessionMeta.delete(key)
  218. sdkCache.delete(key)
  219. clearProviderRev(serverSDK.scope, key)
  220. clearSessionPrefetchDirectory(serverSDK.scope, key)
  221. },
  222. translate: language.t,
  223. queryOptions: queryOptionsApi,
  224. global: {
  225. provider: globalStore.provider,
  226. },
  227. })
  228. async function loadSessions(directory: string, options?: { limit?: number }) {
  229. const key = directoryKey(directory)
  230. const pending = sessionLoads.get(key)
  231. if (pending) {
  232. await pending
  233. return loadSessions(directory, options)
  234. }
  235. children.pin(key)
  236. const [store, setStore] = children.child(directory, { bootstrap: false })
  237. const meta = sessionMeta.get(key)
  238. const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
  239. if (meta && meta.limit >= retainedLimit) {
  240. const next = trimSessions(store.session, {
  241. limit: retainedLimit,
  242. permission: store.permission,
  243. })
  244. if (next.length !== store.session.length) {
  245. setStore("session", reconcile(next, { key: "id" }))
  246. cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
  247. }
  248. children.unpin(key)
  249. return
  250. }
  251. const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
  252. const promise = queryClient
  253. .fetchQuery({
  254. ...queryOptionsApi.sessions(key),
  255. queryFn: () =>
  256. loadRootSessionsWithFallback({
  257. directory,
  258. limit,
  259. list: (query) => serverSDK.client.session.list(query),
  260. })
  261. .then((x) => {
  262. const nonArchived = (x.data ?? [])
  263. .filter((s) => !!s?.id)
  264. .filter((s) => !s.time?.archived)
  265. .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
  266. const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
  267. const childSessions = store.session.filter((s) => !!s.parentID)
  268. const sessions = trimSessions([...nonArchived, ...childSessions], {
  269. limit,
  270. permission: store.permission,
  271. })
  272. batch(() => {
  273. setStore(
  274. "sessionTotal",
  275. estimateRootSessionTotal({
  276. count: nonArchived.length,
  277. limit: x.limit,
  278. limited: x.limited,
  279. }),
  280. )
  281. setStore("session", reconcile(sessions, { key: "id" }))
  282. cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
  283. })
  284. sessionMeta.set(key, { limit })
  285. })
  286. .catch((err) => {
  287. console.error("Failed to load sessions", err)
  288. const project = getFilename(directory)
  289. showToast({
  290. variant: "error",
  291. title: language.t("toast.session.listFailed.title", { project }),
  292. description: formatServerError(err, language.t),
  293. })
  294. })
  295. .then(() => null),
  296. })
  297. .then(() => {})
  298. sessionLoads.set(key, promise)
  299. void promise.finally(() => {
  300. sessionLoads.delete(key)
  301. children.unpin(key)
  302. })
  303. return promise
  304. }
  305. async function bootstrapInstance(directory: string) {
  306. const key = directoryKey(directory)
  307. if (!key) return
  308. const pending = booting.get(key)
  309. if (pending) return pending
  310. children.pin(key)
  311. const promise = Promise.resolve().then(async () => {
  312. const child = children.ensureChild(directory)
  313. const cache = children.vcsCache.get(key)
  314. if (!cache) return
  315. const sdk = sdkFor(directory)
  316. await bootstrapDirectory({
  317. directory,
  318. scope: serverSDK.scope,
  319. mcp: children.mcp(key),
  320. global: {
  321. config: globalStore.config,
  322. path: globalStore.path,
  323. project: globalStore.project,
  324. provider: globalStore.provider,
  325. },
  326. sdk,
  327. store: child[0],
  328. setStore: child[1],
  329. vcsCache: cache,
  330. loadSessions,
  331. translate: language.t,
  332. queryClient,
  333. })
  334. })
  335. booting.set(key, promise)
  336. void promise.finally(() => {
  337. booting.delete(key)
  338. children.unpin(key)
  339. })
  340. return promise
  341. }
  342. const unsub = serverSDK.event.listen((e) => {
  343. const directory = e.name
  344. const key = directoryKey(directory)
  345. const event = e.details
  346. const recent = bootingRoot || Date.now() - bootedAt < 1500
  347. if (directory === "global") {
  348. applyGlobalEvent({
  349. event,
  350. project: globalStore.project,
  351. refresh: () => {
  352. if (recent) return
  353. bootstrap.refetch()
  354. },
  355. setGlobalProject: setProjects,
  356. })
  357. if (event.type === "server.connected" || event.type === "global.disposed") {
  358. if (recent) return
  359. for (const directory of Object.keys(children.children)) {
  360. queue.push(directory)
  361. }
  362. }
  363. return
  364. }
  365. const existing = children.children[key]
  366. if (!existing) return
  367. children.mark(key)
  368. const [store, setStore] = existing
  369. applyDirectoryEvent({
  370. event,
  371. directory,
  372. store,
  373. setStore,
  374. push: queue.push,
  375. setSessionTodo,
  376. retainedLimit: sessionMeta.get(key)?.limit,
  377. vcsCache: children.vcsCache.get(key),
  378. loadLsp: () => {
  379. void queryClient.fetchQuery(queryOptionsApi.lsp(key))
  380. },
  381. })
  382. })
  383. onCleanup(unsub)
  384. onCleanup(() => {
  385. queue.dispose()
  386. })
  387. onCleanup(() => {
  388. for (const directory of Object.keys(children.children)) {
  389. children.disposeDirectory(directoryKey(directory))
  390. }
  391. })
  392. onMount(() => {
  393. if (typeof requestAnimationFrame === "function") {
  394. eventFrame = requestAnimationFrame(() => {
  395. eventFrame = undefined
  396. eventTimer = setTimeout(() => {
  397. eventTimer = undefined
  398. void serverSDK.event.start()
  399. }, 0)
  400. })
  401. } else {
  402. eventTimer = setTimeout(() => {
  403. eventTimer = undefined
  404. void serverSDK.event.start()
  405. }, 0)
  406. }
  407. })
  408. const projectApi = {
  409. loadSessions,
  410. meta(directory: string, patch: ProjectMeta) {
  411. children.projectMeta(directory, patch)
  412. },
  413. icon(directory: string, value: string | undefined) {
  414. children.projectIcon(directory, value)
  415. },
  416. }
  417. const updateConfigMutation = useMutation(() => ({
  418. mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
  419. onSuccess: () => {
  420. bootstrap.refetch()
  421. // Invalidate all provider queries so newly configured custom providers
  422. // appear immediately in the available provider list across all directories.
  423. queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
  424. queryClient.invalidateQueries({
  425. predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
  426. })
  427. },
  428. }))
  429. return {
  430. data: globalStore,
  431. set,
  432. get ready() {
  433. return globalStore.ready
  434. },
  435. get error() {
  436. return globalStore.error
  437. },
  438. child: children.child,
  439. peek: children.peek,
  440. disableMcp: children.disableMcp,
  441. queryOptions: queryOptionsApi,
  442. // bootstrap,
  443. updateConfig: updateConfigMutation.mutateAsync,
  444. project: projectApi,
  445. todo: {
  446. set: setSessionTodo,
  447. },
  448. mcp: {
  449. toggle: async (directory: string, name: string) => {
  450. const key = directoryKey(directory)
  451. const sdk = sdkFor(key)
  452. const status = children.child(key, { bootstrap: false })[0].mcp[name].status
  453. await toggleMcp({
  454. status,
  455. connect: async () => {
  456. await sdk.mcp.connect({ name })
  457. },
  458. disconnect: async () => {
  459. await sdk.mcp.disconnect({ name })
  460. },
  461. authenticate: async () => {
  462. await sdk.mcp.auth.authenticate({ name })
  463. },
  464. refresh: async () => {
  465. await queryClient.refetchQueries(queryOptionsApi.mcp(key))
  466. },
  467. })
  468. },
  469. },
  470. }
  471. }
  472. export function createServerSyncContext(serverSDK: ServerSDK) {
  473. const inner = createServerSyncContextInner(serverSDK)
  474. return Object.assign(inner, {
  475. createDirSyncContext: createRefCountMap(
  476. (dir) => createDirSyncContext(dir, inner, serverSDK),
  477. (dir) => inner.disableMcp(dir),
  478. directoryKey,
  479. ),
  480. })
  481. }
  482. export type ServerSync = ReturnType<typeof createServerSyncContext>
  483. export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
  484. name: "ServerSync",
  485. // Returns an accessor so the resolved server can change reactively without
  486. // re-instantiating the subtree (mirrors useServerSDK).
  487. init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
  488. const global = useGlobal()
  489. const language = useLanguage()
  490. const server = useServer()
  491. return createMemo<ServerSync>(() => {
  492. const conn = props.server?.() ?? server.current
  493. if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
  494. return global.createServerCtx(conn).sync
  495. })
  496. },
  497. })
  498. export function useQueryOptions() {
  499. const sync = useServerSync()
  500. return createMemo(() => sync().queryOptions)
  501. }