bootstrap.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import type {
  2. Config,
  3. OpencodeClient,
  4. Path,
  5. PermissionRequest,
  6. Project,
  7. ProviderAuthResponse,
  8. ProviderListResponse,
  9. QuestionRequest,
  10. Todo,
  11. } from "@opencode-ai/sdk/v2/client"
  12. import { showToast } from "@opencode-ai/ui/toast"
  13. import { getFilename } from "@opencode-ai/util/path"
  14. import { retry } from "@opencode-ai/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, normalizeProviderList } from "./utils"
  19. import { formatServerError } from "@/utils/server-errors"
  20. type GlobalStore = {
  21. ready: boolean
  22. path: Path
  23. project: Project[]
  24. session_todo: {
  25. [sessionID: string]: Todo[]
  26. }
  27. provider: ProviderListResponse
  28. provider_auth: ProviderAuthResponse
  29. config: Config
  30. reload: undefined | "pending" | "complete"
  31. }
  32. function waitForPaint() {
  33. return new Promise<void>((resolve) => {
  34. let done = false
  35. const finish = () => {
  36. if (done) return
  37. done = true
  38. resolve()
  39. }
  40. const timer = setTimeout(finish, 50)
  41. if (typeof requestAnimationFrame !== "function") return
  42. requestAnimationFrame(() => {
  43. clearTimeout(timer)
  44. finish()
  45. })
  46. })
  47. }
  48. function errors(list: PromiseSettledResult<unknown>[]) {
  49. return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
  50. }
  51. function runAll(list: Array<() => Promise<unknown>>) {
  52. return Promise.allSettled(list.map((item) => item()))
  53. }
  54. function showErrors(input: {
  55. errors: unknown[]
  56. title: string
  57. translate: (key: string, vars?: Record<string, string | number>) => string
  58. formatMoreCount: (count: number) => string
  59. }) {
  60. if (input.errors.length === 0) return
  61. const message = formatServerError(input.errors[0], input.translate)
  62. const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
  63. showToast({
  64. variant: "error",
  65. title: input.title,
  66. description: message + more,
  67. })
  68. }
  69. export async function bootstrapGlobal(input: {
  70. globalSDK: OpencodeClient
  71. requestFailedTitle: string
  72. translate: (key: string, vars?: Record<string, string | number>) => string
  73. formatMoreCount: (count: number) => string
  74. setGlobalStore: SetStoreFunction<GlobalStore>
  75. }) {
  76. const fast = [
  77. () =>
  78. retry(() =>
  79. input.globalSDK.path.get().then((x) => {
  80. input.setGlobalStore("path", x.data!)
  81. }),
  82. ),
  83. () =>
  84. retry(() =>
  85. input.globalSDK.global.config.get().then((x) => {
  86. input.setGlobalStore("config", x.data!)
  87. }),
  88. ),
  89. () =>
  90. retry(() =>
  91. input.globalSDK.provider.list().then((x) => {
  92. input.setGlobalStore("provider", normalizeProviderList(x.data!))
  93. }),
  94. ),
  95. ]
  96. const slow = [
  97. () =>
  98. retry(() =>
  99. input.globalSDK.project.list().then((x) => {
  100. const projects = (x.data ?? [])
  101. .filter((p) => !!p?.id)
  102. .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
  103. .slice()
  104. .sort((a, b) => cmp(a.id, b.id))
  105. input.setGlobalStore("project", projects)
  106. }),
  107. ),
  108. ]
  109. showErrors({
  110. errors: errors(await runAll(fast)),
  111. title: input.requestFailedTitle,
  112. translate: input.translate,
  113. formatMoreCount: input.formatMoreCount,
  114. })
  115. await waitForPaint()
  116. showErrors({
  117. errors: errors(await runAll(slow)),
  118. title: input.requestFailedTitle,
  119. translate: input.translate,
  120. formatMoreCount: input.formatMoreCount,
  121. })
  122. input.setGlobalStore("ready", true)
  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. export async function bootstrapDirectory(input: {
  137. directory: string
  138. sdk: OpencodeClient
  139. store: Store<State>
  140. setStore: SetStoreFunction<State>
  141. vcsCache: VcsCache
  142. loadSessions: (directory: string) => Promise<void> | void
  143. translate: (key: string, vars?: Record<string, string | number>) => string
  144. global: {
  145. config: Config
  146. project: Project[]
  147. provider: ProviderListResponse
  148. }
  149. }) {
  150. const loading = input.store.status !== "complete"
  151. const seededProject = projectID(input.directory, input.global.project)
  152. if (seededProject) input.setStore("project", seededProject)
  153. if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
  154. input.setStore("provider", input.global.provider)
  155. }
  156. if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
  157. input.setStore("config", input.global.config)
  158. }
  159. if (loading) input.setStore("status", "partial")
  160. const fast = [
  161. () =>
  162. seededProject
  163. ? Promise.resolve()
  164. : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
  165. () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
  166. () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
  167. () =>
  168. retry(() =>
  169. input.sdk.path.get().then((x) => {
  170. input.setStore("path", x.data!)
  171. const next = projectID(x.data?.directory ?? input.directory, input.global.project)
  172. if (next) input.setStore("project", next)
  173. }),
  174. ),
  175. () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
  176. () =>
  177. retry(() =>
  178. input.sdk.vcs.get().then((x) => {
  179. const next = x.data ?? input.store.vcs
  180. input.setStore("vcs", next)
  181. if (next?.branch) input.vcsCache.setStore("value", next)
  182. }),
  183. ),
  184. () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
  185. () =>
  186. retry(() =>
  187. input.sdk.permission.list().then((x) => {
  188. const grouped = groupBySession(
  189. (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
  190. )
  191. batch(() => {
  192. for (const sessionID of Object.keys(input.store.permission)) {
  193. if (grouped[sessionID]) continue
  194. input.setStore("permission", sessionID, [])
  195. }
  196. for (const [sessionID, permissions] of Object.entries(grouped)) {
  197. input.setStore(
  198. "permission",
  199. sessionID,
  200. reconcile(
  201. permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
  202. { key: "id" },
  203. ),
  204. )
  205. }
  206. })
  207. }),
  208. ),
  209. () =>
  210. retry(() =>
  211. input.sdk.question.list().then((x) => {
  212. const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
  213. batch(() => {
  214. for (const sessionID of Object.keys(input.store.question)) {
  215. if (grouped[sessionID]) continue
  216. input.setStore("question", sessionID, [])
  217. }
  218. for (const [sessionID, questions] of Object.entries(grouped)) {
  219. input.setStore(
  220. "question",
  221. sessionID,
  222. reconcile(
  223. questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
  224. { key: "id" },
  225. ),
  226. )
  227. }
  228. })
  229. }),
  230. ),
  231. ]
  232. const slow = [
  233. () =>
  234. retry(() =>
  235. input.sdk.provider.list().then((x) => {
  236. input.setStore("provider", normalizeProviderList(x.data!))
  237. }),
  238. ),
  239. () => Promise.resolve(input.loadSessions(input.directory)),
  240. () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
  241. () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
  242. ]
  243. const errs = errors(await runAll(fast))
  244. if (errs.length > 0) {
  245. console.error("Failed to bootstrap instance", errs[0])
  246. const project = getFilename(input.directory)
  247. showToast({
  248. variant: "error",
  249. title: input.translate("toast.project.reloadFailed.title", { project }),
  250. description: formatServerError(errs[0], input.translate),
  251. })
  252. }
  253. await waitForPaint()
  254. const slowErrs = errors(await runAll(slow))
  255. if (slowErrs.length > 0) {
  256. console.error("Failed to finish bootstrap instance", slowErrs[0])
  257. const project = getFilename(input.directory)
  258. showToast({
  259. variant: "error",
  260. title: input.translate("toast.project.reloadFailed.title", { project }),
  261. description: formatServerError(slowErrs[0], input.translate),
  262. })
  263. }
  264. if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
  265. }