Files
anomalyco_opencode/packages/app/src/runtime/server/sync.tsx
T
2026-08-21 19:30:00 +00:00

329 lines
11 KiB
TypeScript

import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types"
import { showToast } from "@/shell/notifications/toast"
import { getFilename } from "@opencode-ai/util/path"
import { getOwner, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { type ServerSDK } from "./client"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/runtime/server/errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/workspaces/path-key"
import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
import type { ServerApi } from "@/runtime/server/api"
import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
type GlobalStore = {
path: Path
project: Project[]
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
export const loadLspQuery = (scope: ServerScope, directory: string) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
// TODO: Restore LSP status when the V2 client exposes an LSP API.
queryFn: async () => [],
})
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
path: () => loadPathQuery(scope, null, serverAPI.location),
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
}
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
const language = useLanguage()
const platform = usePlatform()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
const booting = new Map<string, Promise<void>>()
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const [configQuery, pathQuery] = useQueries(() => ({
queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.path(), enabled: connected() },
],
}))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
project: [],
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
if (pathQuery.isLoading) return EMPTY
return pathQuery.data ?? EMPTY
},
get config() {
if (configQuery.isLoading) return {}
return configQuery.data ?? {}
},
get reload() {
return updateConfigMutation.isPending ? "pending" : undefined
},
})
const queryClient = useQueryClient()
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
},
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
bootstrapInstance,
})
const children = createChildStoreManager({
owner,
connected,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: () => false,
onBootstrap: (directory) => {
if (!connected()) return
void bootstrapInstance(directory)
},
onMcp: (directory) => {
void Promise.all([
data.location.command.sync({ directory }),
data.location.mcp.server.sync({ directory }),
data.location.mcp.resource.sync({ directory }),
]).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
},
onDispose: (directory) => {
const key = directoryKey(directory)
queue.clear(key)
},
translate: language.t,
queryOptions: queryOptionsApi,
data,
global: {
get path() {
return globalStore.path
},
},
})
const connection = createConnectionSync({
status: serverSDK.connection.status,
invalidate: () => {
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope,
refetchType: "none",
})
},
connected: (info) => {
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
void data.location.sync({ directory }).catch(() => undefined)
})
},
})
async function bootstrapInstance(directory: string) {
const key = directoryKey(directory)
if (!key) return
const pending = booting.get(key)
if (pending) return pending
children.pin(key)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
await Promise.all([
data.location.sync({ directory }),
bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
},
api: serverSDK.api,
store: child[0],
setStore: child[1],
translate: language.t,
queryClient,
}),
])
})
booting.set(key, promise)
void promise.finally(() => {
booting.delete(key)
children.unpin(key)
})
return promise
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
return
}
const directory = event.location.directory
const key = directoryKey(directory)
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
onCleanup(() => {
queue.dispose()
})
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory))
}
})
const projectApi = {
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
icon(directory: string, value: string | undefined) {
children.projectIcon(directory, value)
},
}
const updateConfigMutation = useMutation(() => ({
mutationFn: async (config: Config) => {
// TODO: Restore config updates when the V2 client exposes a config API.
// await serverSDK.api.config.update({ config })
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
},
onSuccess: () => {
bootstrap.refetch()
data.location.provider.invalidate()
data.location.model.invalidate()
void Promise.all([data.location.provider.sync(), data.location.model.sync()])
},
}))
return {
data: globalStore,
set,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
if (!status) return
await toggleMcp({
status,
connect: async () => {
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
(item) => item.name === name,
)
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
const integration = await serverSDK.api.integration.get({
integrationID: server.integrationID,
location: { directory: key },
})
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
location: { directory: key },
})
platform.openExternal(attempt.data.url)
},
refresh: async () => {
data.location.mcp.server.invalidate({ directory: key })
data.location.mcp.resource.invalidate({ directory: key })
await Promise.all([
data.location.mcp.server.sync({ directory: key }),
data.location.mcp.resource.sync({ directory: key }),
])
},
})
},
},
}
}
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
return createServerSyncContextInner(serverSDK, data)
}
export type ServerSync = ReturnType<typeof createServerSyncContext>