perf(startup): build the local tool environment in the background and gate its consumers
Runtime creation, MCP launches, Obsidian, native clients and the tool-environment IPC await the initialization promise instead of startup awaiting it before the window path. A failure still fails startup with the same diagnostic.
This commit is contained in:
@@ -81,7 +81,10 @@ import {
|
|||||||
listAllMcpTools,
|
listAllMcpTools,
|
||||||
normalizeMcpToolSchema
|
normalizeMcpToolSchema
|
||||||
} from './mcp-tool-utils'
|
} from './mcp-tool-utils'
|
||||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
import type {
|
||||||
|
LaunchEnvironmentProvider,
|
||||||
|
LaunchEnvironmentReady
|
||||||
|
} from '../local-tool-environment'
|
||||||
import {
|
import {
|
||||||
BrowserModelTools,
|
BrowserModelTools,
|
||||||
browserNavigateInputSchema,
|
browserNavigateInputSchema,
|
||||||
@@ -270,6 +273,8 @@ export type KnowledgeMcpGatewayOptions = {
|
|||||||
): BrowserTabUsageLease
|
): BrowserTabUsageLease
|
||||||
}
|
}
|
||||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||||
|
/** Awaited before reading the provider; startup builds the environment in the background. */
|
||||||
|
launchEnvironmentReady?: LaunchEnvironmentReady
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMagicNoteToolSummary(
|
function toMagicNoteToolSummary(
|
||||||
@@ -400,6 +405,8 @@ export class KnowledgeMcpGateway {
|
|||||||
): BrowserTabUsageLease
|
): BrowserTabUsageLease
|
||||||
}
|
}
|
||||||
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||||
|
private readonly launchEnvironmentReady?: LaunchEnvironmentReady
|
||||||
|
|
||||||
private readonly observeFailure?: DesktopDiagnosticFailureObserver
|
private readonly observeFailure?: DesktopDiagnosticFailureObserver
|
||||||
private server?: Server
|
private server?: Server
|
||||||
private endpoint?: string
|
private endpoint?: string
|
||||||
@@ -416,6 +423,8 @@ export class KnowledgeMcpGateway {
|
|||||||
this.obsidianService = options.obsidianService
|
this.obsidianService = options.obsidianService
|
||||||
this.browserService = options.browserService
|
this.browserService = options.browserService
|
||||||
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
||||||
|
this.launchEnvironmentReady = options.launchEnvironmentReady
|
||||||
|
|
||||||
this.observeFailure = options.observeFailure
|
this.observeFailure = options.observeFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,6 +1057,7 @@ export class KnowledgeMcpGateway {
|
|||||||
capability.brokerController.signal
|
capability.brokerController.signal
|
||||||
])
|
])
|
||||||
try {
|
try {
|
||||||
|
await this.launchEnvironmentReady?.()
|
||||||
await client.connect(
|
await client.connect(
|
||||||
createMcpTransport(server, this.launchEnvironmentProvider),
|
createMcpTransport(server, this.launchEnvironmentProvider),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ afterEach(async () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
function fixture() {
|
function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
||||||
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
||||||
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
||||||
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
||||||
@@ -37,7 +37,7 @@ function fixture() {
|
|||||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'],
|
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'],
|
||||||
getObsidianSettings: async () => ({}) },
|
getObsidianSettings: async () => ({}) },
|
||||||
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
||||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env },
|
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
|
||||||
rootDirectory: '/clients', createGateway: () => gateway, openExternal
|
rootDirectory: '/clients', createGateway: () => gateway, openExternal
|
||||||
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
|
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
|
||||||
coordinators.push(coordinator)
|
coordinators.push(coordinator)
|
||||||
@@ -45,6 +45,16 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('native client coordinator', () => {
|
describe('native client coordinator', () => {
|
||||||
|
it('waits for the startup tool environment before launching a native client', async () => {
|
||||||
|
let ready!: () => void
|
||||||
|
const { coordinator, start } = fixture(() => new Promise<void>(resolve => { ready = resolve }))
|
||||||
|
const opened = coordinator.open(1, 'conversation')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
expect(start).not.toHaveBeenCalled()
|
||||||
|
ready()
|
||||||
|
await expect(opened).resolves.toEqual({ kind: 'browser', serviceId: 'service-0' })
|
||||||
|
expect(start).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => {
|
it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => {
|
||||||
const { coordinator, gateway, start, stop, openExternal } = fixture()
|
const { coordinator, gateway, start, stop, openExternal } = fixture()
|
||||||
openExternal.mockRejectedValueOnce(new Error('Browser unavailable'))
|
openExternal.mockRejectedValueOnce(new Error('Browser unavailable'))
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type Options = {
|
|||||||
capabilities: CapabilityService
|
capabilities: CapabilityService
|
||||||
executionSpaceResolver: ExecutionSpaceResolver
|
executionSpaceResolver: ExecutionSpaceResolver
|
||||||
terminalManager: TerminalSessionManager
|
terminalManager: TerminalSessionManager
|
||||||
localEnvironment: LocalToolEnvironmentService
|
localEnvironment: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
|
||||||
bundledRuntimePaths: BundledRuntimePaths
|
bundledRuntimePaths: BundledRuntimePaths
|
||||||
rootDirectory: string
|
rootDirectory: string
|
||||||
managedNodeDirectory: string
|
managedNodeDirectory: string
|
||||||
@@ -122,6 +122,9 @@ export class NativeClientCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
|
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
|
||||||
|
this.assertOwner(ownerId)
|
||||||
|
// Native clients launch with the prepared tool PATH; startup builds it in the background.
|
||||||
|
await this.options.localEnvironment.whenReady()
|
||||||
this.assertOwner(ownerId)
|
this.assertOwner(ownerId)
|
||||||
const context = await this.resolve(conversationId)
|
const context = await this.resolve(conversationId)
|
||||||
this.assertOwner(ownerId)
|
this.assertOwner(ownerId)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function registerCapabilityIpcHandlers(
|
|||||||
runtimeExtensionStore?: RuntimeExtensionStore
|
runtimeExtensionStore?: RuntimeExtensionStore
|
||||||
knowledgeGateway?: Pick<KnowledgeMcpGateway, 'revokeObsidianCapabilities'>
|
knowledgeGateway?: Pick<KnowledgeMcpGateway, 'revokeObsidianCapabilities'>
|
||||||
obsidianService?: Pick<ObsidianService, 'testConnection'>
|
obsidianService?: Pick<ObsidianService, 'testConnection'>
|
||||||
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider'>
|
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
registerHandler(
|
registerHandler(
|
||||||
@@ -266,6 +266,8 @@ export function registerCapabilityIpcHandlers(
|
|||||||
ipcChannels.capabilitiesTestMcp,
|
ipcChannels.capabilitiesTestMcp,
|
||||||
async (event, input: unknown): Promise<McpServerTestResult> => {
|
async (event, input: unknown): Promise<McpServerTestResult> => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
|
// A stdio MCP test must launch with the prepared tool PATH.
|
||||||
|
await localToolEnvironmentService?.whenReady()
|
||||||
return testMcpServer(
|
return testMcpServer(
|
||||||
await capabilityService.getResolvedMcpServer(
|
await capabilityService.getResolvedMcpServer(
|
||||||
mcpServerIdSchema.parse(input)
|
mcpServerIdSchema.parse(input)
|
||||||
|
|||||||
+28
-5
@@ -677,7 +677,18 @@ if (hasSingleInstanceLock) {
|
|||||||
},
|
},
|
||||||
baseEnvironment: buildCredentialFilteredUserEnvironment()
|
baseEnvironment: buildCredentialFilteredUserEnvironment()
|
||||||
})
|
})
|
||||||
await startupSpan('main:local-tool-environment', () => startupLocalToolEnvironmentService.initialize())
|
// Built in the background (P6): only runtime launches read the launch
|
||||||
|
// environment, and each of them awaits this first. A failure still fails
|
||||||
|
// startup with the same diagnostic once the prerequisites have settled.
|
||||||
|
let localToolEnvironmentFailed = false
|
||||||
|
let localToolEnvironmentError: unknown
|
||||||
|
const localToolEnvironmentReady = startupSpan(
|
||||||
|
'main:local-tool-environment',
|
||||||
|
() => startupLocalToolEnvironmentService.initialize()
|
||||||
|
).catch((error: unknown) => {
|
||||||
|
localToolEnvironmentFailed = true
|
||||||
|
localToolEnvironmentError = error
|
||||||
|
})
|
||||||
localToolEnvironmentService =
|
localToolEnvironmentService =
|
||||||
startupLocalToolEnvironmentService
|
startupLocalToolEnvironmentService
|
||||||
const sshHostStore = new SshHostStore(
|
const sshHostStore = new SshHostStore(
|
||||||
@@ -1038,7 +1049,8 @@ if (hasSingleInstanceLock) {
|
|||||||
const obsidianService = new ObsidianService({
|
const obsidianService = new ObsidianService({
|
||||||
appPath: app.getAppPath(),
|
appPath: app.getAppPath(),
|
||||||
launchEnvironmentProvider:
|
launchEnvironmentProvider:
|
||||||
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||||
|
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||||
})
|
})
|
||||||
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
||||||
startupKnowledgeService,
|
startupKnowledgeService,
|
||||||
@@ -1054,7 +1066,8 @@ if (hasSingleInstanceLock) {
|
|||||||
obsidianService,
|
obsidianService,
|
||||||
browserService,
|
browserService,
|
||||||
launchEnvironmentProvider:
|
launchEnvironmentProvider:
|
||||||
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||||
|
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
knowledgeGateway = startupKnowledgeGateway
|
knowledgeGateway = startupKnowledgeGateway
|
||||||
@@ -1063,6 +1076,8 @@ if (hasSingleInstanceLock) {
|
|||||||
target: SelectedRuntimeTarget,
|
target: SelectedRuntimeTarget,
|
||||||
executionSpace?: ExecutionSpaceDescriptor
|
executionSpace?: ExecutionSpaceDescriptor
|
||||||
): Promise<AgentRuntime> => {
|
): Promise<AgentRuntime> => {
|
||||||
|
await localToolEnvironmentReady
|
||||||
|
if (localToolEnvironmentFailed) throw localToolEnvironmentError
|
||||||
const [
|
const [
|
||||||
skillContext,
|
skillContext,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
@@ -1271,7 +1286,14 @@ if (hasSingleInstanceLock) {
|
|||||||
)
|
)
|
||||||
startupMark('main:assistant-db-init:end')
|
startupMark('main:assistant-db-init:end')
|
||||||
}
|
}
|
||||||
}))
|
})).catch(async (error: unknown) => {
|
||||||
|
await localToolEnvironmentReady
|
||||||
|
throw localToolEnvironmentFailed ? localToolEnvironmentError : error
|
||||||
|
})
|
||||||
|
// Runtime hydration awaited the environment, so it has settled here. Keep
|
||||||
|
// the previous failure behaviour: an environment error fails startup.
|
||||||
|
await localToolEnvironmentReady
|
||||||
|
if (localToolEnvironmentFailed) throw localToolEnvironmentError
|
||||||
const [initialSubagentRuntime, initialSubagentProfileRuntimes] =
|
const [initialSubagentRuntime, initialSubagentProfileRuntimes] =
|
||||||
await startupSpan('main:subagent-runtimes', () => Promise.all([
|
await startupSpan('main:subagent-runtimes', () => Promise.all([
|
||||||
createSubagentRuntime(initialResolvedSettings),
|
createSubagentRuntime(initialResolvedSettings),
|
||||||
@@ -1523,7 +1545,8 @@ if (hasSingleInstanceLock) {
|
|||||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
||||||
},
|
},
|
||||||
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
||||||
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
|
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
||||||
|
launchEnvironmentReady: () => localToolEnvironmentReady
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
startupMark('main:ipc-register:start')
|
startupMark('main:ipc-register:start')
|
||||||
|
|||||||
@@ -9270,7 +9270,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
coordinator = new NativeClientCoordinator({ database, settingsStore,
|
coordinator = new NativeClientCoordinator({ database, settingsStore,
|
||||||
applicationSettingsStore,
|
applicationSettingsStore,
|
||||||
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
|
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
|
||||||
localEnvironment: { launchEnvironmentProvider: () => process.env } as never,
|
localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady: async () => undefined } as never,
|
||||||
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
||||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||||
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ export type {
|
|||||||
LocalToolEnvironmentServiceDependencies,
|
LocalToolEnvironmentServiceDependencies,
|
||||||
LocalToolEnvironmentServiceOptions
|
LocalToolEnvironmentServiceOptions
|
||||||
} from './local-tool-environment-service'
|
} from './local-tool-environment-service'
|
||||||
export type { LaunchEnvironmentProvider } from './launch-environment-provider'
|
export type {
|
||||||
|
LaunchEnvironmentProvider,
|
||||||
|
LaunchEnvironmentReady
|
||||||
|
} from './launch-environment-provider'
|
||||||
|
|||||||
@@ -3,3 +3,9 @@
|
|||||||
* tool launches. Consumers must only copy the values they explicitly support.
|
* tool launches. Consumers must only copy the values they explicitly support.
|
||||||
*/
|
*/
|
||||||
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
|
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles once the launch environment has been built at startup. Consumers that
|
||||||
|
* can run before startup finishes await it before reading the provider.
|
||||||
|
*/
|
||||||
|
export type LaunchEnvironmentReady = () => Promise<void>
|
||||||
@@ -217,6 +217,51 @@ describe('LocalToolEnvironmentService', () => {
|
|||||||
expect(Object.isFrozen(updated)).toBe(true)
|
expect(Object.isFrozen(updated)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts initialization once and lets consumers wait for the built environment', async () => {
|
||||||
|
const { service } = await fixture()
|
||||||
|
await expect(service.whenReady()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
const started = service.initialize()
|
||||||
|
expect(service.initialize()).toBe(started)
|
||||||
|
expect(service.launchEnvironmentProvider()).toEqual({})
|
||||||
|
|
||||||
|
await service.whenReady()
|
||||||
|
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||||
|
await started
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a settings update after an in-flight initialization, never before it', async () => {
|
||||||
|
const { service, node } = await fixture()
|
||||||
|
const order: string[] = []
|
||||||
|
const initialization = service.initialize().then(() => {
|
||||||
|
order.push('initialized')
|
||||||
|
})
|
||||||
|
const update = service
|
||||||
|
.updateSettings({
|
||||||
|
...defaultLocalToolEnvironmentSettings,
|
||||||
|
node: { source: 'custom', executablePath: node }
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
order.push('updated')
|
||||||
|
})
|
||||||
|
await Promise.all([initialization, update])
|
||||||
|
|
||||||
|
expect(order).toEqual(['initialized', 'updated'])
|
||||||
|
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a failed initialization through whenReady and still disposes', async () => {
|
||||||
|
const { service, options } = await fixture()
|
||||||
|
const failing = new LocalToolEnvironmentService(
|
||||||
|
{ ...options, pythonArtifactCatalogPath: join(options.managedPythonRoot, 'missing.json') },
|
||||||
|
{ toolEnvironment: { platform: 'win32', spawnProcess: fakeInspectionSpawn() } }
|
||||||
|
)
|
||||||
|
await expect(failing.initialize()).rejects.toThrow()
|
||||||
|
await expect(failing.whenReady()).rejects.toThrow()
|
||||||
|
await expect(failing.dispose()).resolves.toBeUndefined()
|
||||||
|
await service.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps executable paths inside the Main-owned picker callback', async () => {
|
it('keeps executable paths inside the Main-owned picker callback', async () => {
|
||||||
const { service, settingsStore, python } = await fixture()
|
const { service, settingsStore, python } = await fixture()
|
||||||
const snapshot = await service.selectExecutable('python')
|
const snapshot = await service.selectExecutable('python')
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ export class LocalToolEnvironmentService {
|
|||||||
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
|
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
|
||||||
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
|
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
|
||||||
private listeners = new Set<ProgressListener>()
|
private listeners = new Set<ProgressListener>()
|
||||||
|
private initialization?: Promise<void>
|
||||||
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
|
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
|
||||||
this.launchEnvironment
|
this.launchEnvironment
|
||||||
|
|
||||||
@@ -530,15 +531,35 @@ export class LocalToolEnvironmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
/**
|
||||||
await rm(this.options.binDirectory, {
|
* Starts building the launch environment once. Startup no longer awaits this
|
||||||
recursive: true,
|
* before showing the window (P6); every consumer that reads
|
||||||
force: true
|
* `launchEnvironmentProvider` or mutates the environment awaits
|
||||||
})
|
* `whenReady()` first, so no launch observes the empty pre-start snapshot.
|
||||||
await this.rebuildLaunchEnvironment()
|
*/
|
||||||
|
initialize(): Promise<void> {
|
||||||
|
this.initialization ??= (async () => {
|
||||||
|
await rm(this.options.binDirectory, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
await this.rebuildLaunchEnvironment()
|
||||||
|
})()
|
||||||
|
return this.initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settles with the result of `initialize()`; resolves immediately if it never started. */
|
||||||
|
whenReady(): Promise<void> {
|
||||||
|
return this.initialization ?? Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for initialization without failing; the startup path reports its error. */
|
||||||
|
private async initialized(): Promise<void> {
|
||||||
|
await this.whenReady().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
|
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
|
||||||
|
await this.initialized()
|
||||||
const catalog = await loadPythonArtifactCatalog(
|
const catalog = await loadPythonArtifactCatalog(
|
||||||
this.options.pythonArtifactCatalogPath
|
this.options.pythonArtifactCatalogPath
|
||||||
)
|
)
|
||||||
@@ -562,6 +583,7 @@ export class LocalToolEnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
|
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
|
||||||
|
await this.initialized()
|
||||||
let settings = localToolEnvironmentSettingsSchema.parse(input)
|
let settings = localToolEnvironmentSettingsSchema.parse(input)
|
||||||
const current = await this.settings()
|
const current = await this.settings()
|
||||||
for (const kind of ['node', 'python'] as const) {
|
for (const kind of ['node', 'python'] as const) {
|
||||||
@@ -629,6 +651,7 @@ export class LocalToolEnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
|
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
|
||||||
|
await this.initialized()
|
||||||
const environment = await this.rebuildLaunchEnvironment(
|
const environment = await this.rebuildLaunchEnvironment(
|
||||||
this.operationController?.signal
|
this.operationController?.signal
|
||||||
)
|
)
|
||||||
@@ -767,6 +790,7 @@ export class LocalToolEnvironmentService {
|
|||||||
|
|
||||||
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
|
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
|
||||||
if (this.operationController) throw new Error('A Managed Python operation is running')
|
if (this.operationController) throw new Error('A Managed Python operation is running')
|
||||||
|
await this.initialized()
|
||||||
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
|
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
|
||||||
await removeManagedPython({
|
await removeManagedPython({
|
||||||
rootDirectory: this.options.managedPythonRoot,
|
rootDirectory: this.options.managedPythonRoot,
|
||||||
@@ -781,6 +805,7 @@ export class LocalToolEnvironmentService {
|
|||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.cancelPython()
|
this.cancelPython()
|
||||||
await this.installOperation?.catch(() => undefined)
|
await this.installOperation?.catch(() => undefined)
|
||||||
|
await this.initialized()
|
||||||
await rm(this.options.binDirectory, {
|
await rm(this.options.binDirectory, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
force: true
|
force: true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||||
import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js'
|
import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment'
|
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment'
|
||||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
import type { LaunchEnvironmentProvider, LaunchEnvironmentReady } from '../local-tool-environment/launch-environment-provider'
|
||||||
|
|
||||||
export type ObsidianSettings = { vaultPath?: string }
|
export type ObsidianSettings = { vaultPath?: string }
|
||||||
export type ObsidianVault = { id: string; name: string; path: string }
|
export type ObsidianVault = { id: string; name: string; path: string }
|
||||||
@@ -14,6 +14,8 @@ export type ObsidianServiceOptions = {
|
|||||||
/** Electron app.getAppPath(), or the project root in development. */
|
/** Electron app.getAppPath(), or the project root in development. */
|
||||||
appPath: string
|
appPath: string
|
||||||
launchEnvironmentProvider: LaunchEnvironmentProvider
|
launchEnvironmentProvider: LaunchEnvironmentProvider
|
||||||
|
/** Awaited before reading the provider; startup builds the environment in the background. */
|
||||||
|
launchEnvironmentReady?: LaunchEnvironmentReady
|
||||||
homePath?: string
|
homePath?: string
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
environment?: NodeJS.ProcessEnv
|
environment?: NodeJS.ProcessEnv
|
||||||
@@ -122,6 +124,7 @@ export class ObsidianService {
|
|||||||
await stat(serverPath).catch((cause) => {
|
await stat(serverPath).catch((cause) => {
|
||||||
throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause })
|
throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause })
|
||||||
})
|
})
|
||||||
|
await this.options.launchEnvironmentReady?.()
|
||||||
const env = applyLaunchEnvironmentPath(
|
const env = applyLaunchEnvironmentPath(
|
||||||
buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env),
|
buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env),
|
||||||
this.options.launchEnvironmentProvider
|
this.options.launchEnvironmentProvider
|
||||||
|
|||||||
Reference in New Issue
Block a user