bootstrap.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import type {
  2. Config,
  3. OpencodeClient,
  4. Path,
  5. PermissionRequest,
  6. Project,
  7. ProviderAuthResponse,
  8. QuestionRequest,
  9. Session,
  10. Todo,
  11. } from "@opencode-ai/sdk/v2/client"
  12. import { showToast } from "@/utils/toast"
  13. import { getFilename } from "@opencode-ai/core/util/path"
  14. import { retry } from "@opencode-ai/core/util/retry"
  15. import { batch } from "solid-js"
  16. import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
  17. import type { State, VcsCache } from "./types"
  18. import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
  19. import { formatServerError } from "@/utils/server-errors"
  20. import { QueryClient, queryOptions } from "@tanstack/solid-query"
  21. import { loadMcpQuery } from "../server-sync"
  22. import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
  23. import { ScopedKey, type ServerScope } from "@/utils/server-scope"
  24. type GlobalStore = {
  25. ready: boolean
  26. path: Path
  27. project: Project[]
  28. session_todo: {
  29. [sessionID: string]: Todo[]
  30. }
  31. provider: NormalizedProviderListResponse
  32. provider_auth: ProviderAuthResponse
  33. config: Config
  34. reload: undefined | "pending" | "complete"
  35. }
  36. function waitForPaint() {
  37. return new Promise<void>((resolve) => {
  38. let done = false
  39. const finish = () => {
  40. if (done) return
  41. done = true
  42. resolve()
  43. }
  44. const timer = setTimeout(finish, 50)
  45. if (typeof requestAnimationFrame !== "function") return
  46. requestAnimationFrame(() => {
  47. setTimeout(() => {
  48. clearTimeout(timer)
  49. finish()
  50. }, 0)
  51. })
  52. })
  53. }
  54. function errors(list: PromiseSettledResult<unknown>[]) {
  55. return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
  56. }
  57. const providerRev = new Map<string, number>()
  58. export function clearProviderRev(scope: ServerScope, directory: string) {
  59. providerRev.delete(ScopedKey.from(scope, directory))
  60. }
  61. function runAll(list: Array<() => Promise<unknown>>) {
  62. return Promise.allSettled(list.map((item) => item()))
  63. }
  64. function showErrors(input: {
  65. errors: unknown[]
  66. title: string
  67. translate: (key: string, vars?: Record<string, string | number>) => string
  68. formatMoreCount: (count: number) => string
  69. }) {
  70. if (input.errors.length === 0) return
  71. const message = formatServerError(input.errors[0], input.translate)
  72. const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
  73. showToast({
  74. variant: "error",
  75. title: input.title,
  76. description: message + more,
  77. })
  78. }
  79. export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
  80. queryOptions({
  81. queryKey: [scope, "config"],
  82. queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
  83. })
  84. export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
  85. queryOptions({
  86. queryKey: [scope, "project"],
  87. queryFn: () =>
  88. retry(() =>
  89. sdk.project.list().then((x) => {
  90. return (x.data ?? [])
  91. .filter((p) => !!p?.id)
  92. .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
  93. .slice()
  94. .sort((a, b) => cmp(a.id, b.id))
  95. }),
  96. ),
  97. })
  98. export async function bootstrapGlobal(input: {
  99. serverSDK: OpencodeClient
  100. scope: ServerScope
  101. requestFailedTitle: string
  102. translate: (key: string, vars?: Record<string, string | number>) => string
  103. formatMoreCount: (count: number) => string
  104. setGlobalStore: SetStoreFunction<GlobalStore>
  105. queryClient: QueryClient
  106. }) {
  107. const slow = [
  108. () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
  109. () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
  110. () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
  111. () =>
  112. input.queryClient
  113. .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
  114. .then((data) => input.setGlobalStore("project", data)),
  115. ]
  116. await runAll(slow)
  117. // showErrors({
  118. // errors: errors(),
  119. // title: input.requestFailedTitle,
  120. // translate: input.translate,
  121. // formatMoreCount: input.formatMoreCount,
  122. // })
  123. }
  124. function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
  125. return input.reduce<Record<string, T[]>>((acc, item) => {
  126. if (!item?.id || !item.sessionID) return acc
  127. const list = acc[item.sessionID]
  128. if (list) list.push(item)
  129. if (!list) acc[item.sessionID] = [item]
  130. return acc
  131. }, {})
  132. }
  133. function projectID(directory: string, projects: Project[]) {
  134. return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
  135. }
  136. function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
  137. setStore("session", (list) => {
  138. const next = list.slice()
  139. const idx = next.findIndex((item) => item.id >= session.id)
  140. if (idx === -1) return [...next, session]
  141. if (next[idx]?.id === session.id) {
  142. next[idx] = session
  143. return next
  144. }
  145. next.splice(idx, 0, session)
  146. return next
  147. })
  148. }
  149. function warmSessions(input: {
  150. ids: string[]
  151. store: Store<State>
  152. setStore: SetStoreFunction<State>
  153. sdk: OpencodeClient
  154. }) {
  155. const known = new Set(input.store.session.map((item) => item.id))
  156. const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
  157. if (ids.length === 0) return Promise.resolve()
  158. return Promise.all(
  159. ids.map((sessionID) =>
  160. retry(() => input.sdk.session.get({ sessionID })).then((x) => {
  161. const session = x.data
  162. if (!session?.id) return
  163. mergeSession(input.setStore, session)
  164. }),
  165. ),
  166. ).then(() => undefined)
  167. }
  168. export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
  169. queryOptions({
  170. queryKey: [scope, directory, "providers"],
  171. queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
  172. })
  173. export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
  174. queryOptions({
  175. queryKey: [scope, directory, "agents"],
  176. queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
  177. })
  178. export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
  179. queryOptions<Path>({
  180. queryKey: [scope, directory, "path"],
  181. queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
  182. })
  183. export async function bootstrapDirectory(input: {
  184. directory: string
  185. scope: ServerScope
  186. mcp: boolean
  187. sdk: OpencodeClient
  188. store: Store<State>
  189. setStore: SetStoreFunction<State>
  190. vcsCache: VcsCache
  191. loadSessions: (directory: string) => Promise<void> | void
  192. translate: (key: string, vars?: Record<string, string | number>) => string
  193. global: {
  194. config: Config
  195. path: Path
  196. project: Project[]
  197. provider: NormalizedProviderListResponse
  198. }
  199. queryClient: QueryClient
  200. }) {
  201. const loading = input.store.status !== "complete"
  202. const seededProject = projectID(input.directory, input.global.project)
  203. const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
  204. if (seededProject) input.setStore("project", seededProject)
  205. if (seededPath) input.setStore("path", seededPath)
  206. if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
  207. input.setStore("config", reconcile(input.global.config, { merge: false }))
  208. }
  209. if (loading) input.setStore("status", "partial")
  210. const revKey = ScopedKey.from(input.scope, input.directory)
  211. const rev = (providerRev.get(revKey) ?? 0) + 1
  212. providerRev.set(revKey, rev)
  213. ;(async () => {
  214. const slow = [
  215. () => Promise.resolve(input.loadSessions(input.directory)),
  216. () =>
  217. input.queryClient
  218. .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
  219. .then((data) => input.setStore("agent", data)),
  220. () =>
  221. retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
  222. () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
  223. !seededProject &&
  224. (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
  225. !seededPath &&
  226. (() =>
  227. input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
  228. const next = projectID(data.directory ?? input.directory, input.global.project)
  229. if (next) input.setStore("project", next)
  230. })),
  231. () =>
  232. retry(() =>
  233. input.sdk.vcs.get().then((x) => {
  234. const next = x.data ?? input.store.vcs
  235. input.setStore("vcs", next)
  236. if (next) input.vcsCache.setStore("value", next)
  237. }),
  238. ),
  239. input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
  240. () =>
  241. retry(() =>
  242. input.sdk.permission.list().then((x) => {
  243. const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
  244. const grouped = groupBySession(
  245. (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
  246. )
  247. return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
  248. batch(() => {
  249. for (const sessionID of Object.keys(input.store.permission)) {
  250. if (grouped[sessionID]) continue
  251. input.setStore("permission", sessionID, [])
  252. }
  253. for (const [sessionID, permissions] of Object.entries(grouped)) {
  254. input.setStore(
  255. "permission",
  256. sessionID,
  257. reconcile(
  258. permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
  259. { key: "id" },
  260. ),
  261. )
  262. }
  263. }),
  264. )
  265. }),
  266. ),
  267. () =>
  268. retry(() =>
  269. input.sdk.question.list().then((x) => {
  270. const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
  271. const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
  272. return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
  273. batch(() => {
  274. for (const sessionID of Object.keys(input.store.question)) {
  275. if (grouped[sessionID]) continue
  276. input.setStore("question", sessionID, [])
  277. }
  278. for (const [sessionID, questions] of Object.entries(grouped)) {
  279. input.setStore(
  280. "question",
  281. sessionID,
  282. reconcile(
  283. questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
  284. { key: "id" },
  285. ),
  286. )
  287. }
  288. }),
  289. )
  290. }),
  291. ),
  292. () => Promise.resolve(input.loadSessions(input.directory)),
  293. input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
  294. () =>
  295. input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
  296. const project = getFilename(input.directory)
  297. showToast({
  298. variant: "error",
  299. title: input.translate("toast.project.reloadFailed.title", { project }),
  300. description: formatServerError(err, input.translate),
  301. })
  302. }),
  303. ].filter(Boolean) as (() => Promise<any>)[]
  304. await waitForPaint()
  305. const slowErrs = errors(await runAll(slow))
  306. if (slowErrs.length > 0) {
  307. console.error("Failed to finish bootstrap instance", slowErrs[0])
  308. const project = getFilename(input.directory)
  309. showToast({
  310. variant: "error",
  311. title: input.translate("toast.project.reloadFailed.title", { project }),
  312. description: formatServerError(slowErrs[0], input.translate),
  313. })
  314. }
  315. if (loading && slowErrs.length === 0) input.setStore("status", "complete")
  316. })()
  317. }