bootstrap.ts 13 KB

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