server-sync.tsx 18 KB

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