feat: add instance config provider and map storage ids

This commit is contained in:
Shantur Rathore
2025-11-20 14:46:13 +00:00
parent 038cf3c762
commit 3f46d73a31
14 changed files with 247 additions and 131 deletions

View File

@@ -1,4 +1,5 @@
import type {
AgentModelSelection,
AgentModelSelections,
ConfigFile,
ModelPreference,
@@ -107,6 +108,7 @@ export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
}
export interface BinaryRecord {

View File

@@ -13,7 +13,6 @@ const PreferencesSchema = z.object({
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
agentModelSelections: AgentModelSelectionsSchema.default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),

View File

@@ -67,7 +67,11 @@ export function createHttpServer(deps: HttpServerDeps) {
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -2,25 +2,36 @@ import { FastifyInstance } from "fastify"
import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
instanceStore: InstanceStore
eventBus: EventBus
workspaceManager: WorkspaceManager
}
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
const resolveStorageKey = (instanceId: string): string => {
const workspace = deps.workspaceManager.get(instanceId)
return workspace?.path ?? instanceId
}
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const data = await deps.instanceStore.read(request.params.id)
const storageId = resolveStorageKey(request.params.id)
const data = await deps.instanceStore.read(storageId)
return data
} catch (error) {
reply.code(500)
@@ -31,7 +42,8 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const body = InstanceDataSchema.parse(request.body ?? {})
await deps.instanceStore.write(request.params.id, body)
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.write(storageId, body)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
reply.code(204)
} catch (error) {
@@ -42,7 +54,8 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
await deps.instanceStore.delete(request.params.id)
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.delete(storageId)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
reply.code(204)
} catch (error) {

View File

@@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export class InstanceStore {

View File

@@ -6,6 +6,7 @@ export type ConfigData = AppConfig
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
function isDeepEqual(a: unknown, b: unknown): boolean {
@@ -150,9 +151,11 @@ export class ServerStorage {
private normalizeInstanceData(data?: InstanceData | null): InstanceData {
const source = data ?? DEFAULT_INSTANCE_DATA
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
return {
...source,
messageHistory,
agentModelSelections,
}
}

View File

@@ -2,6 +2,7 @@ import { render } from "solid-js/web"
import App from "./App"
import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config"
import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -14,9 +15,11 @@ if (!root) {
render(
() => (
<ConfigProvider>
<ThemeProvider>
<App />
</ThemeProvider>
<InstanceConfigProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</InstanceConfigProvider>
</ConfigProvider>
),
root,

View File

@@ -0,0 +1,138 @@
import { createContext, createMemo, createSignal, onCleanup, type Accessor, type ParentComponent, useContext } from "solid-js"
import type { InstanceData } from "../../../cli/src/api-types"
import { storage } from "../lib/storage"
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
const loadPromises = new Map<string, Promise<void>>()
const instanceSubscriptions = new Map<string, () => void>()
function cloneInstanceData(data?: InstanceData | null): InstanceData {
const source = data ?? DEFAULT_INSTANCE_DATA
return {
...source,
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
}
}
function attachSubscription(instanceId: string) {
if (instanceSubscriptions.has(instanceId)) return
const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => {
setInstanceData(instanceId, data)
})
instanceSubscriptions.set(instanceId, unsubscribe)
}
function detachSubscription(instanceId: string) {
const unsubscribe = instanceSubscriptions.get(instanceId)
if (!unsubscribe) return
unsubscribe()
instanceSubscriptions.delete(instanceId)
}
function setInstanceData(instanceId: string, data: InstanceData) {
setInstanceDataMap((prev) => {
const next = new Map(prev)
next.set(instanceId, cloneInstanceData(data))
return next
})
}
async function ensureInstanceConfig(instanceId: string): Promise<void> {
if (!instanceId) return
if (instanceDataMap().has(instanceId)) return
if (loadPromises.has(instanceId)) {
await loadPromises.get(instanceId)
return
}
const promise = storage
.loadInstanceData(instanceId)
.then((data) => {
setInstanceData(instanceId, data)
attachSubscription(instanceId)
})
.catch((error) => {
console.warn("Failed to load instance data:", error)
setInstanceData(instanceId, DEFAULT_INSTANCE_DATA)
attachSubscription(instanceId)
})
.finally(() => {
loadPromises.delete(instanceId)
})
loadPromises.set(instanceId, promise)
await promise
}
async function updateInstanceConfig(instanceId: string, mutator: (draft: InstanceData) => void): Promise<void> {
if (!instanceId) return
await ensureInstanceConfig(instanceId)
const current = instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
const draft = cloneInstanceData(current)
mutator(draft)
try {
await storage.saveInstanceData(instanceId, draft)
} catch (error) {
console.warn("Failed to persist instance data:", error)
}
setInstanceData(instanceId, draft)
}
function getInstanceConfig(instanceId: string): InstanceData {
return instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
}
function useInstanceConfig(instanceId: string): Accessor<InstanceData> {
const context = useContext(InstanceConfigContext)
if (!context) {
throw new Error("useInstanceConfig must be used within InstanceConfigProvider")
}
return createMemo(() => instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA)
}
function clearInstanceConfig(instanceId: string): void {
setInstanceDataMap((prev) => {
if (!prev.has(instanceId)) return prev
const next = new Map(prev)
next.delete(instanceId)
return next
})
detachSubscription(instanceId)
}
interface InstanceConfigContextValue {
getInstanceConfig: typeof getInstanceConfig
ensureInstanceConfig: typeof ensureInstanceConfig
updateInstanceConfig: typeof updateInstanceConfig
clearInstanceConfig: typeof clearInstanceConfig
}
const InstanceConfigContext = createContext<InstanceConfigContextValue>()
const contextValue: InstanceConfigContextValue = {
getInstanceConfig,
ensureInstanceConfig,
updateInstanceConfig,
clearInstanceConfig,
}
const InstanceConfigProvider: ParentComponent = (props) => {
onCleanup(() => {
for (const unsubscribe of instanceSubscriptions.values()) {
unsubscribe()
}
instanceSubscriptions.clear()
})
return <InstanceConfigContext.Provider value={contextValue}>{props.children}</InstanceConfigContext.Provider>
}
export {
InstanceConfigProvider,
useInstanceConfig,
ensureInstanceConfig as ensureInstanceConfigLoaded,
getInstanceConfig,
updateInstanceConfig,
clearInstanceConfig,
}

View File

@@ -7,6 +7,7 @@ import { sseManager } from "../lib/sse-manager"
import { cliApi } from "../lib/api-client"
import { cliEvents } from "../lib/cli-events"
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types"
import { ensureInstanceConfigLoaded } from "./instance-config"
import {
fetchSessions,
fetchAgents,
@@ -20,6 +21,7 @@ import { computeDisplayParts } from "./session-messages"
import { withSession, setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
@@ -116,6 +118,7 @@ async function hydrateInstanceData(instanceId: string) {
await fetchSessions(instanceId)
await fetchAgents(instanceId)
await fetchProviders(instanceId)
await ensureInstanceConfigLoaded(instanceId)
const instance = instances().get(instanceId)
if (!instance?.client) return
await fetchCommands(instanceId, instance.client)

View File

@@ -1,88 +1,35 @@
import type { InstanceData } from "../../../cli/src/api-types"
import { storage } from "../lib/storage"
import {
ensureInstanceConfigLoaded,
getInstanceConfig,
updateInstanceConfig,
} from "./instance-config"
const MAX_HISTORY = 100
const instanceDataCache = new Map<string, InstanceData>()
const instanceSubscriptions = new Map<string, () => void>()
export async function addToHistory(instanceId: string, text: string): Promise<void> {
const data = await ensureInstanceData(instanceId)
const nextHistory = [text, ...data.messageHistory]
if (nextHistory.length > MAX_HISTORY) {
nextHistory.length = MAX_HISTORY
}
const nextData: InstanceData = {
...data,
messageHistory: nextHistory,
}
instanceDataCache.set(instanceId, cloneInstanceData(nextData))
try {
await storage.saveInstanceData(instanceId, nextData)
} catch (err) {
console.warn("Failed to persist message history:", err)
}
if (!instanceId || !text) return
await ensureInstanceConfigLoaded(instanceId)
await updateInstanceConfig(instanceId, (draft) => {
const nextHistory = [text, ...(draft.messageHistory ?? [])]
if (nextHistory.length > MAX_HISTORY) {
nextHistory.length = MAX_HISTORY
}
draft.messageHistory = nextHistory
})
}
export async function getHistory(instanceId: string): Promise<string[]> {
const data = await ensureInstanceData(instanceId)
return [...data.messageHistory]
if (!instanceId) return []
await ensureInstanceConfigLoaded(instanceId)
const data = getInstanceConfig(instanceId)
return [...(data.messageHistory ?? [])]
}
export async function clearHistory(instanceId: string): Promise<void> {
const data = await ensureInstanceData(instanceId)
const nextData: InstanceData = {
...data,
messageHistory: [],
}
instanceDataCache.set(instanceId, cloneInstanceData(nextData))
try {
await storage.saveInstanceData(instanceId, nextData)
} catch (error) {
console.warn("Failed to clear history:", error)
}
}
async function ensureInstanceData(instanceId: string): Promise<InstanceData> {
const cached = instanceDataCache.get(instanceId)
if (cached) {
return cached
}
try {
const data = await storage.loadInstanceData(instanceId)
const normalized = cloneInstanceData(data)
instanceDataCache.set(instanceId, normalized)
attachInstanceSubscription(instanceId)
return normalized
} catch (error) {
console.warn("Failed to load history:", error)
const fallback = cloneInstanceData({ messageHistory: [] })
instanceDataCache.set(instanceId, fallback)
attachInstanceSubscription(instanceId)
return fallback
}
}
function attachInstanceSubscription(instanceId: string) {
if (instanceSubscriptions.has(instanceId)) {
return
}
const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => {
instanceDataCache.set(instanceId, cloneInstanceData(data))
if (!instanceId) return
await ensureInstanceConfigLoaded(instanceId)
await updateInstanceConfig(instanceId, (draft) => {
draft.messageHistory = []
})
instanceSubscriptions.set(instanceId, unsubscribe)
}
function cloneInstanceData(data?: InstanceData | null): InstanceData {
const source: InstanceData = data ?? { messageHistory: [] }
return {
...source,
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
}
}

View File

@@ -1,6 +1,11 @@
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
import type { Accessor, ParentComponent } from "solid-js"
import { storage, type ConfigData } from "../lib/storage"
import {
ensureInstanceConfigLoaded,
getInstanceConfig,
updateInstanceConfig as updateInstanceData,
} from "./instance-config"
type DeepReadonly<T> = T extends (...args: any[]) => unknown
? T
@@ -27,7 +32,6 @@ export interface Preferences {
lastUsedBinary?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
agentModelSelections: AgentModelSelections
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
@@ -53,7 +57,6 @@ const defaultPreferences: Preferences = {
showThinkingBlocks: false,
environmentVariables: {},
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
@@ -71,32 +74,24 @@ function deepEqual(a: unknown, b: unknown): boolean {
return false
}
function normalizePreferences(pref?: Partial<Preferences>): Preferences {
function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelections?: unknown }): Preferences {
const sanitized = pref ?? {}
const environmentVariables = {
...defaultPreferences.environmentVariables,
...(pref?.environmentVariables ?? {}),
...(sanitized.environmentVariables ?? {}),
}
const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections
const agentModelSelections: AgentModelSelections = {}
for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) {
agentModelSelections[instanceId] = Object.fromEntries(
Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]),
)
}
return {
showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables,
modelRecents,
agentModelSelections,
diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
}
}
@@ -122,10 +117,22 @@ function buildFallbackConfig(): ConfigData {
return normalizeConfig()
}
function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } {
const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections)
const cleanedConfig = normalizeConfig(config)
return { cleaned: cleanedConfig, migrated }
}
async function syncConfig(source?: ConfigData): Promise<void> {
try {
const configData = source ?? (await storage.loadConfig())
applyConfig(configData)
const loaded = source ?? (await storage.loadConfig())
const { cleaned, migrated } = removeLegacyAgentSelections(loaded)
applyConfig(cleaned)
if (migrated) {
void storage.updateConfig(cleaned).catch((error: unknown) => {
console.error("Failed to persist legacy config cleanup:", error)
})
}
} catch (error) {
console.error("Failed to load config:", error)
applyConfig(buildFallbackConfig())
@@ -328,27 +335,25 @@ function addRecentModelPreference(model: ModelPreference): void {
updatePreferences({ modelRecents: updated })
}
function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): void {
async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise<void> {
if (!instanceId || !agent || !model.providerId || !model.modelId) return
const selections = preferences().agentModelSelections ?? {}
const instanceSelections = selections[instanceId] ?? {}
const existing = instanceSelections[agent]
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
return
}
updatePreferences({
agentModelSelections: {
...selections,
[instanceId]: {
...instanceSelections,
[agent]: model,
},
},
await ensureInstanceConfigLoaded(instanceId)
await updateInstanceData(instanceId, (draft) => {
const selections = { ...(draft.agentModelSelections ?? {}) }
const existing = selections[agent]
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
return
}
selections[agent] = model
draft.agentModelSelections = selections
})
}
function getAgentModelPreference(instanceId: string, agent: string): ModelPreference | undefined {
return preferences().agentModelSelections?.[instanceId]?.[agent]
async function getAgentModelPreference(instanceId: string, agent: string): Promise<ModelPreference | undefined> {
if (!instanceId || !agent) return undefined
await ensureInstanceConfigLoaded(instanceId)
const selections = getInstanceConfig(instanceId).agentModelSelections ?? {}
return selections[agent]
}
void ensureConfigLoaded().catch((error: unknown) => {

View File

@@ -306,7 +306,7 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent:
})
if (agent && shouldApplyModel) {
setAgentModelPreference(instanceId, agent, nextModel)
await setAgentModelPreference(instanceId, agent, nextModel)
}
if (shouldApplyModel) {
@@ -335,7 +335,7 @@ async function updateSessionModel(
})
if (session.agent) {
setAgentModelPreference(instanceId, session.agent, model)
await setAgentModelPreference(instanceId, session.agent, model)
}
addRecentModelPreference(model)

View File

@@ -159,7 +159,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
}
setLoading((prev) => {

View File

@@ -32,13 +32,6 @@ async function getDefaultModel(
const instanceProviders = providers().get(instanceId) || []
const instanceAgents = agents().get(instanceId) || []
if (agentName) {
const stored = getAgentModelPreference(instanceId, agentName)
if (isModelValid(instanceId, stored)) {
return stored
}
}
if (agentName) {
const agent = instanceAgents.find((a) => a.name === agentName)
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
@@ -47,6 +40,11 @@ async function getDefaultModel(
modelId: agent.model.modelId,
}
}
const stored = await getAgentModelPreference(instanceId, agentName)
if (isModelValid(instanceId, stored)) {
return stored
}
}
const recent = getRecentModelPreferenceForInstance(instanceId)