diff --git a/.playwright/scripts/__tests__/desktop.e2e.mjs b/.playwright/scripts/__tests__/desktop.e2e.mjs index 3506dce6..2e737355 100644 --- a/.playwright/scripts/__tests__/desktop.e2e.mjs +++ b/.playwright/scripts/__tests__/desktop.e2e.mjs @@ -24,7 +24,9 @@ test('three concurrent instances isolate their data and survive another instance }, index) expect(await (await app.browserWindow(page)).evaluate(window => window.getTitle())).toBe('Lexora Buddy Test') })) - await applications[0].app.evaluate(({ session }) => session.defaultSession.flushStorageData()) + await instances[0].stop() + applications[0] = await instances[0].launch() + expect(await applications[0].page.evaluate(() => localStorage.getItem('test-instance'))).toBe('0') const crashed = applications[0].app.process() const exited = once(crashed, 'exit') crashed.kill('SIGKILL') diff --git a/.playwright/scripts/__tests__/extensionAgent.e2e.mjs b/.playwright/scripts/__tests__/extensionAgent.e2e.mjs new file mode 100644 index 00000000..00e991fb --- /dev/null +++ b/.playwright/scripts/__tests__/extensionAgent.e2e.mjs @@ -0,0 +1,310 @@ +import { Buffer } from 'node:buffer' +import { once } from 'node:events' +import fs from 'node:fs/promises' +import { createServer } from 'node:http' +import path from 'node:path' +import process from 'node:process' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from '../fixtures/electron.mjs' + +test('agent plugin uses the originating model, persists settings and protects manual titles and cancelled writes', async ({ buddy }) => { + test.setTimeout(180000) + const requests = [] + const pending = [] + let hold = false + let generated = 0 + const server = createServer(async (request, response) => { + if (request.url !== '/v1/chat/completions') { + response.writeHead(404).end() + return + } + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const body = JSON.parse(Buffer.concat(chunks).toString()) + requests.push(body) + const common = { id: `title-${requests.length}`, model: body.model, object: 'chat.completion.chunk', created: 1 } + function send(delta, reason = 'stop') { + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', ...delta }, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: reason }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } })}\n\n`) + response.end('data: [DONE]\n\n') + } + if (!body.tools?.length) { + const finish = () => send({ content: `生成标题 ${++generated}` }) + if (hold) + pending.push(finish) + else finish() + return + } + const tool = body.tools.find(tool => tool.function.description.includes('Generate and save a concise title')) + if (tool && body.messages.at(-1).role !== 'tool') { + send({ tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: { name: tool.function.name, arguments: JSON.stringify({ summary: '整理本周项目计划' }) } }] }, 'tool_calls') + } + else { + send({ content: '任务已处理。' }) + } + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const instance = await buddy.createInstance('agent-title') + try { + let { app, page, diagnostics } = await instance.launch() + await syntheticCredentials(app, page) + await page.evaluate(async (baseUrl) => { + const providers = window.lexoraDesktop.localChat.providers + await providers.upsertCustom({ id: 'title-fixture', displayName: 'Title fixture', api: 'openai-completions', baseUrl, enabled: true, models: ['primary', 'secondary'].map(id => ({ id, name: id, input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 })) }) + const stop = providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'title-fixture' && challenge.type === 'secret') + void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await providers.login('title-fixture', 'api_key') + } + finally { stop() } + await providers.setDefaultModel({ providerId: 'title-fixture', modelId: 'primary', reasoning: null }) + }, `http://127.0.0.1:${server.address().port}/v1`) + const source = process.env.LEXORA_TEST_TITLE_PLUGIN ?? path.join(instance.home, 'fixture-plugin') + if (!process.env.LEXORA_TEST_TITLE_PLUGIN) + await writeFixture(source) + await page.reload() + async function install(target, updating = false) { + const development = (await fs.stat(target)).isDirectory() + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await app.evaluate(({ dialog }, target) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [target] }) + }, target) + await page.getByTestId('extension-install-options').click() + await page.getByTestId(development ? 'extension-development' : 'extension-install').click() + await expect(page.getByText('调用已配置的模型', { exact: true })).toBeVisible() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async (updating) => { + const plugin = (await window.lexoraDesktop.extensions.list())[0] + return !!plugin && (!updating || !!plugin.pending) + }, updating)).toBe(true) + } + const previous = process.env.LEXORA_TEST_TITLE_PLUGIN_PREVIOUS + if (previous) { + await install(previous) + await page.evaluate(async () => { + const plugin = (await window.lexoraDesktop.extensions.list())[0] + await window.lexoraDesktop.extensions.configure(plugin.manifest.id, { enabled: false, model: { providerId: 'title-fixture', modelId: 'secondary' } }) + }) + } + await install(source, !!previous) + const pluginId = await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].manifest.id) + if (previous) { + await page.locator(`[data-extension-id="${pluginId}"]`).getByTestId('extension-card-more').click() + await page.getByTestId('extension-restart').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].pending)).toBeNull() + expect(await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ enabled: false, model: { providerId: 'title-fixture', modelId: 'secondary' } }) + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { enabled: true, model: null }), pluginId) + } + if (process.env.LEXORA_TEST_TITLE_PLUGIN) { + const icon = page.locator(`[data-extension-id="${pluginId}"] img`) + await expect(icon).toBeVisible() + await expect.poll(() => icon.evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'title-plugin-card.png'), animations: 'disabled' }) + } + const settings = async () => { + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.locator(`[data-extension-id="${pluginId}"]`).getByRole('button', { name: '打开', exact: true }).click() + } + const taskPage = () => page.locator('.desktop-app-sidebar').getByRole('button', { name: '任务', exact: true }).click() + await settings() + if (process.env.LEXORA_TEST_TITLE_PLUGIN) { + await expect(page).toHaveURL(/#\/settings\/runtime\?group=lexora.auto-title.naming$/) + await expect(page.getByRole('link', { name: '标题自动生成', exact: true })).toHaveCount(0) + await expect(page.locator('[data-settings-group="lexora.auto-title.naming"]')).toBeFocused() + expect(await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].iconUrl)).toMatch(/^data:image\/svg\+xml;base64,/) + } + const toggle = () => page.locator(`[data-setting-id="${pluginId}.enabled"]`).getByRole('switch') + await expect(toggle()).toBeChecked() + await toggle().click() + await expect(toggle()).not.toBeChecked() + await expect.poll(() => page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ enabled: false }) + await toggle().click() + await expect(toggle()).toBeChecked() + await expect.poll(() => page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ enabled: true }) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'title-settings.png'), animations: 'disabled' }) + if (!process.env.LEXORA_TEST_TITLE_PLUGIN) { + await page.getByRole('link', { name: '常规', exact: true }).click() + await expect(page.getByText('附加分组', { exact: true })).toBeVisible() + await expect(page.locator(`[data-setting-id="${pluginId}.inline"]`)).toBeVisible() + } + await taskPage() + async function send(text, newTask = false) { + if (newTask) { + await page.getByRole('button', { name: '新任务', exact: true }).click() + await expect(page.locator('.desktop-chat-workspace-header__copy:visible')).toHaveText('新任务') + await expect(page.locator('.desktop-chat-page.is-empty:not(.is-loading):visible')).toBeVisible() + } + const count = rows(instance.home, 'SELECT id FROM runs').length + await page.locator('.desktop-chat-composer__prosemirror:visible').fill(text) + await page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect.poll(() => rows(instance.home, 'SELECT id FROM runs').length).toBe(count + 1) + } + const settled = async () => { + await expect.poll(() => rows(instance.home, 'SELECT id FROM runs WHERE status IN (\'queued\', \'running\')').length).toBe(0) + expect(rows(instance.home, 'SELECT id, error_code FROM runs WHERE status = \'failed\'')).toEqual([]) + } + const latestTask = () => rows(instance.home, 'SELECT * FROM conversations ORDER BY created_at DESC LIMIT 1')[0] + await send('整理本周项目计划') + await settled() + expect(latestTask()).toMatchObject({ title: '生成标题 1', title_source: 'generated' }) + expect(requests.filter(body => !body.tools?.length).map(body => body.model)).toEqual(['primary']) + expect(JSON.stringify(requests[0].messages)).toContain('proactively') + const pluginToolName = requests[0].tools.find(tool => tool.function.description.includes('Generate and save a concise title')).function.name + await expect(page.getByText('生成标题 1', { exact: true }).first()).toBeVisible() + + await settings() + const modelField = () => page.locator(`[data-setting-id="${pluginId}.model"]`) + async function chooseSecondary() { + await modelField().locator('.plugin-model-setting__select').click() + await expect(page.locator('.desktop-model-picker__providers')).toContainText('Title fixture') + await page.locator('.desktop-model-picker__search input').fill('secondary') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'title-model-picker.png'), animations: 'disabled' }) + await page.getByRole('menuitemradio', { name: 'secondary secondary', exact: true }).click() + await expect(modelField()).toContainText('secondary · Title fixture') + } + await chooseSecondary() + await modelField().getByRole('button', { name: '使用本轮模型', exact: true }).click() + await expect.poll(() => page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ model: null }) + await expect(modelField()).toContainText('使用本轮模型') + await chooseSecondary() + await expect.poll(() => page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ model: { providerId: 'title-fixture', modelId: 'secondary' } }) + await taskPage() + await send('重新整理发布计划', true) + await settled() + expect(latestTask()).toMatchObject({ title: '生成标题 2', title_source: 'generated' }) + expect(requests.filter(body => !body.tools?.length).map(body => body.model)).toEqual(['primary', 'secondary']) + expect(requests.filter(body => body.tools?.length).every(body => body.model === 'primary')).toBe(true) + expect(rows(instance.home, 'SELECT provider, model, purpose FROM usage_records WHERE source_entry_id LIKE \'plugin:%\'').map(row => row.model)).toEqual(['primary', 'secondary']) + + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { model: { providerId: 'title-fixture', modelId: 'unavailable' } }), pluginId) + await send('指定模型不可用时保留原名', true) + await settled() + expect(latestTask()).toMatchObject({ title: '指定模型不可用时保留原名', title_source: 'fallback' }) + expect(requests.filter(body => !body.tools?.length)).toHaveLength(2) + await settings() + await expect(modelField()).toContainText('unavailable · Title fixture(不可用)') + await chooseSecondary() + await taskPage() + + hold = true + await send('等待命名时手动改名', true) + await expect.poll(() => pending.length).toBe(1) + const original = latestTask() + await page.evaluate(id => window.lexoraDesktop.localChat.conversations.rename(id, '手动保留'), original.id) + expect(latestTask()).toMatchObject({ title: '手动保留', updated_at: original.updated_at }) + pending.shift()() + await settled() + expect(latestTask()).toMatchObject({ title: '手动保留', title_source: 'manual' }) + const beforeProtected = requests.filter(body => !body.tools?.length).length + await send('继续同一任务') + await settled() + expect(requests.filter(body => !body.tools?.length)).toHaveLength(beforeProtected) + + await send('取消任务时不写入迟到标题', true) + await expect.poll(() => pending.length).toBe(1) + const beforeCancel = latestTask() + const activeRun = rows(instance.home, 'SELECT id FROM runs WHERE status = \'running\'')[0] + await page.evaluate(id => window.lexoraDesktop.localChat.chat.cancel(id), activeRun.id) + pending.shift()() + await settled() + expect(latestTask()).toMatchObject({ title: beforeCancel.title, title_source: 'fallback' }) + + hold = false + const resumedTitle = `生成标题 ${generated + 1}` + await send('取消后继续同一任务') + await settled() + expect(latestTask()).toMatchObject({ id: beforeCancel.id, title: resumedTitle, title_source: 'generated' }) + + hold = true + await send('禁用时不写入迟到标题', true) + await expect.poll(() => pending.length).toBe(1) + const beforeDisable = latestTask() + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { enabled: false }), pluginId) + pending.shift()() + await settled() + expect(latestTask()).toMatchObject({ title: beforeDisable.title, title_source: 'fallback' }) + const count = requests.length + await send('关闭功能后继续任务', true) + await settled() + expect(requests.slice(count).every(body => !body.tools?.some(tool => tool.function.name === pluginToolName))).toBe(true) + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + await settings() + await expect(toggle()).not.toBeChecked() + await expect(modelField()).toContainText('secondary · Title fixture') + expect(await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ enabled: false, model: { providerId: 'title-fixture', modelId: 'secondary' } }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + +function rows(home, sql) { + const database = new DatabaseSync(path.join(home, 'buddy/buddy.sqlite3'), { readOnly: true }) + try { + return database.prepare(sql).all() + } + finally { database.close() } +} + +async function syntheticCredentials(app, page) { + await app.evaluate(({ app, safeStorage }) => { + if (app.getName() !== 'Lexora Buddy Test') + throw new Error('Synthetic credentials require an isolated test instance') + Object.defineProperties(safeStorage, { + isEncryptionAvailable: { configurable: true, value: () => true }, + getSelectedStorageBackend: { configurable: true, value: () => 'offline-fixture' }, + encryptString: { configurable: true, value: value => Buffer.from(`offline-fixture:${value}`) }, + decryptString: { configurable: true, value: value => value.toString('utf8').slice('offline-fixture:'.length) }, + }) + }) + await page.evaluate(() => window.lexoraDesktop.localChat.runtime.restart()) + await expect.poll(async () => (await page.evaluate(() => window.lexoraDesktop.localChat.runtime.getStatus())).status).toBe('ready') +} + +async function writeFixture(directory) { + const id = 'tests.title' + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, 'extension.json'), JSON.stringify({ + schemaVersion: 1, + id, + name: '标题自动生成', + version: '1.0.0', + apiVersion: 3, + engines: { lexora: '*' }, + entry: 'extension.js', + permissions: { agent: true, models: true, tasks: 'title' }, + contributes: { + settings: { + modules: [{ id: `${id}.settings`, title: '标题自动生成' }], + groups: [{ id: `${id}.group`, module: `${id}.settings`, title: '命名' }, { id: `${id}.extra`, module: 'settings.general', title: '附加分组' }], + items: [ + { id: `${id}.enabled`, key: 'enabled', group: `${id}.group`, type: 'boolean', title: '启用', default: true }, + { id: `${id}.model`, key: 'model', group: `${id}.group`, type: 'model', title: '模型', default: null }, + { id: `${id}.inline`, key: 'inline', group: 'settings.general.general', type: 'boolean', title: '分组内单项', default: false }, + { id: `${id}.extra-item`, key: 'extra', group: `${id}.extra`, type: 'string', title: '附加字段', default: '' }, + ], + }, + agent: { enabledWhen: 'enabled', instructions: `Please proactively call {{${id}.generate}} to name the task.`, tools: [{ id: `${id}.generate`, title: '生成标题', description: 'Generate and save a concise title', parameters: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'], additionalProperties: false } }] }, + }, + })) + await fs.writeFile(path.join(directory, 'extension.js'), `export const activate = ${activate.toString()}`) +} + +function activate(context) { + context.agent.registerTool(`${context.extension.id}.generate`, async (input, invocation) => { + const task = await invocation.task.get() + if (task.titleSource === 'manual') + return { applied: false } + const configuration = await context.configuration.get() + const result = await invocation.models.generateText({ prompt: input.summary, model: configuration.model }) + return invocation.task.rename({ title: result.text, expectedRevision: task.titleRevision }) + }) +} diff --git a/.playwright/scripts/__tests__/settings.e2e.mjs b/.playwright/scripts/__tests__/settings.e2e.mjs new file mode 100644 index 00000000..f5fb1dd1 --- /dev/null +++ b/.playwright/scripts/__tests__/settings.e2e.mjs @@ -0,0 +1,410 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +test('registered builtin settings preserve navigation, failed-save rollback and restart persistence', async ({ buddy }) => { + const instance = await buddy.createInstance('settings') + let desktop = await instance.launch() + await openSettings(desktop.page) + const globalToggle = () => desktop.page.getByTestId('context-panel-global-setting').getByRole('switch') + await expect(globalToggle()).not.toBeChecked() + await globalToggle().click() + await expect(globalToggle()).toBeChecked() + await desktop.page.getByTestId('context-panel-mode-setting').locator('.n-select').click() + await desktop.page.locator('.n-base-select-menu').getByText('独立浏览', { exact: true }).click() + await expect.poll(() => panelSettings(desktop.page)).toEqual({ global: true, mode: 'independent' }) + + const entries = await desktop.page.locator('.desktop-settings-sidebar a').evaluateAll(links => links.map(link => ({ label: link.textContent.trim(), href: link.getAttribute('href') }))) + expect(entries.length).toBeGreaterThanOrEqual(13) + for (const { label, href } of entries) { + await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: label, exact: true }).click() + await expect(desktop.page.locator('.desktop-settings-page__title')).toHaveText(label) + await expect(desktop.page.locator('.settings-groups')).toBeVisible() + await expect(desktop.page.locator(`.desktop-settings-sidebar a[href="${href}"]`)).toHaveAttribute('aria-current', 'page') + } + await desktop.page.evaluate(() => window.location.hash = '/settings/app') + await expect(desktop.page).toHaveURL(/#\/settings\/general$/) + await desktop.page.evaluate(() => window.location.hash = '/settings/extensions') + await expect(desktop.page).toHaveURL(/#\/extensions$/) + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + + await instance.stop() + desktop = await instance.launch() + await openSettings(desktop.page) + expect(await panelSettings(desktop.page)).toEqual({ global: true, mode: 'independent' }) + await expect(globalToggle()).toBeChecked() + await expect(desktop.page.getByTestId('context-panel-mode-setting').locator('.n-base-selection-label')).toHaveText('独立浏览') + await desktop.app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('lexora:settings:update') + ipcMain.handle('lexora:settings:update', () => { + throw new Error('Isolated settings save failure') + }) + }) + await globalToggle().click() + await expect(desktop.page.getByText('保存失败,已恢复原设置', { exact: true })).toBeVisible() + await expect(globalToggle()).toBeEnabled() + await expect(globalToggle()).toBeChecked() + expect(await panelSettings(desktop.page)).toEqual({ global: true, mode: 'independent' }) + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + +test('registered plugin settings share groups, recover read/save errors and withdraw without deleting values', async ({ buddy }) => { + const instance = await buddy.createInstance('settings-registry') + let { app, page, diagnostics } = await instance.launch() + const directory = path.join(instance.home, 'settings-plugin') + await writeSettingsPlugin(directory) + await app.evaluate(({ ipcMain, dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + const original = ipcMain._invokeHandlers.get('lexora:extensions:request') + globalThis.settingsFixtureFailures = { load: true, save: false } + globalThis.settingsFixtureSaves = [] + ipcMain.removeHandler('lexora:extensions:request') + ipcMain.handle('lexora:extensions:request', async (event, request) => { + if (request.action === 'configure') { + globalThis.settingsFixtureSaves.push(request.patch) + await globalThis.settingsFixtureSaveGate + } + const { load, save } = globalThis.settingsFixtureFailures + if ((load && request.action === 'configurationSnapshot') || (save && request.action === 'configure')) + throw new Error('private-settings-fixture-detail') + return original(event, request) + }) + }, directory) + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.getByTestId('extension-install-options').click() + await page.getByTestId('extension-development').click() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list()).length)).toBe(1) + await openSettings(page) + const inline = () => page.locator('[data-setting-id="tests.settings.inline"]').getByRole('switch') + await expect(page.locator('[data-settings-group="settings.general.general"] [data-setting-id="tests.settings.inline"]')).toBeVisible() + await expect(page.locator('[data-settings-group="tests.settings.extra"]')).toContainText('附加分组') + await page.getByRole('link', { name: '注册设置', exact: true }).click() + const toggle = () => page.locator('[data-setting-id="tests.settings.enabled"]').getByRole('switch') + await expect(toggle()).toBeDisabled() + await expect(page.locator('.plugin-settings-status__entry')).toHaveCount(1) + await app.evaluate(() => globalThis.settingsFixtureFailures.load = false) + await page.getByRole('button', { name: '重试', exact: true }).click() + await expect(toggle()).toBeEnabled() + await expect(page.locator('.plugin-settings-status__entry')).toHaveCount(0) + await expect(toggle()).not.toBeChecked() + + await app.evaluate(() => globalThis.settingsFixtureFailures.save = true) + await toggle().click() + await expect(page.locator('.n-message')).toHaveCount(1) + await expect(toggle()).not.toBeChecked() + await expect(toggle()).toBeEnabled() + await expect(page.locator('body')).not.toContainText('private-settings-fixture-detail') + await app.evaluate(() => globalThis.settingsFixtureFailures.save = false) + await toggle().click() + await expect(toggle()).toBeChecked() + + const label = () => page.locator('[data-setting-id="tests.settings.label"] input') + await expect(label()).toBeEnabled() + await label().pressSequentially('editable') + await expect(label()).toHaveValue('editable') + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ label: '' }) + await app.evaluate(() => { + globalThis.settingsFixtureSaves = [] + globalThis.settingsFixtureSaveGate = new Promise(resolve => globalThis.releaseSettingsFixtureSave = resolve) + }) + await label().press('Tab') + await expect(label()).toBeDisabled() + await expect(label()).toHaveValue('editable') + expect(await app.evaluate(() => globalThis.settingsFixtureSaves)).toEqual([{ label: 'editable' }]) + await app.evaluate(() => globalThis.releaseSettingsFixtureSave()) + await expect(label()).toBeEnabled() + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ label: 'editable' }) + await app.evaluate(() => globalThis.settingsFixtureFailures.save = true) + await label().fill('rejected') + await expect(label()).toHaveValue('rejected') + await label().press('Enter') + await expect(label()).toHaveValue('editable') + await expect(label()).toBeEnabled() + await app.evaluate(() => globalThis.settingsFixtureFailures.save = false) + await label().fill('粘贴后的名称') + await expect(label()).toHaveValue('粘贴后的名称') + await label().press('Enter') + await expect.poll(() => page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ label: '粘贴后的名称' }) + + await page.getByRole('link', { name: '常规', exact: true }).click() + const number = () => page.locator('[data-setting-id="tests.settings.extra-item"] input') + await expect(number()).toBeEnabled() + await app.evaluate(() => { + globalThis.settingsFixtureSaves = [] + globalThis.settingsFixtureSaveGate = new Promise(resolve => globalThis.releaseSettingsFixtureSave = resolve) + }) + await number().fill('') + await number().pressSequentially('12') + await expect(number()).toHaveValue('12') + await expect(number()).toBeEnabled() + expect(await app.evaluate(() => globalThis.settingsFixtureSaves)).toEqual([]) + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ extra: 0 }) + await number().press('Enter') + await expect(number()).toBeDisabled() + await expect(number()).toHaveValue('12') + expect(await app.evaluate(() => globalThis.settingsFixtureSaves)).toEqual([{ extra: 12 }]) + await app.evaluate(() => globalThis.releaseSettingsFixtureSave()) + await expect(number()).toBeEnabled() + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ extra: 12 }) + await app.evaluate(() => globalThis.settingsFixtureFailures.save = true) + await number().fill('34') + await number().press('Tab') + await expect(number()).toHaveValue('12') + await expect(number()).toBeEnabled() + await app.evaluate(() => globalThis.settingsFixtureFailures.save = false) + await number().fill('56') + await number().press('Tab') + await expect.poll(() => page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ extra: 56 }) + await inline().click() + await expect(inline()).toBeChecked() + await expect(page.locator('.n-message')).toHaveCount(0) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'registered-settings-light.png'), animations: 'disabled' }) + await page.getByRole('link', { name: '外观', exact: true }).click() + await page.locator('.desktop-settings-row').filter({ has: page.getByText('主题', { exact: true }) }).locator('.n-select').click() + await page.locator('.n-base-select-menu').getByText('深色', { exact: true }).click() + await expect(page.locator('.buddy-app')).toHaveClass(/is-dark/) + await page.getByRole('link', { name: '常规', exact: true }).click() + await page.screenshot({ path: path.join(instance.artifactDirectory, 'registered-settings-dark.png'), animations: 'disabled' }) + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find(window => window.isVisible()).setSize(980, 680)) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'registered-settings-narrow.png'), animations: 'disabled' }) + await page.getByRole('link', { name: '日志', exact: true }).click() + await expect(page.locator('[data-settings-group="tests.settings.logs"]')).toContainText('日志附加分组') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'registered-settings-fill.png'), animations: 'disabled' }) + + await page.getByRole('link', { name: '注册设置', exact: true }).click() + await page.evaluate(() => window.lexoraDesktop.extensions.enable('tests.settings', false)) + await expect(page.getByRole('link', { name: '注册设置', exact: true })).toHaveCount(0) + await expect(page.locator('.desktop-settings-page').getByRole('status')).toContainText('此设置模块暂不可用') + await page.evaluate(() => window.lexoraDesktop.extensions.enable('tests.settings', true)) + await expect(toggle()).toBeChecked() + await expect(page.getByRole('link', { name: '注册设置', exact: true })).toHaveAttribute('aria-current', 'page') + + await instance.stop() + const workbenchPath = path.join(instance.home, 'buddy/workbench.json') + const retainedWorkbench = JSON.parse(await fs.readFile(workbenchPath, 'utf8')) + retainedWorkbench.configuration['workbench.controls.model.reasoning'] = 'tests.settings.retired-control' + retainedWorkbench.configuration['workbench.slots.composer.accessory'] = JSON.stringify(['tests.settings.retired-slot', 'tests.settings-other.retained']) + await fs.writeFile(workbenchPath, JSON.stringify(retainedWorkbench)) + ;({ app, page, diagnostics } = await instance.launch()) + await openSettings(page) + await expect(inline()).toBeChecked() + await expect(number()).toHaveValue('56') + await page.getByRole('link', { name: '注册设置', exact: true }).click() + await expect(toggle()).toBeChecked() + await expect(label()).toHaveValue('粘贴后的名称') + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.locator('[data-extension-id="tests.settings"]').getByTestId('extension-card-more').click() + await page.getByTestId('extension-uninstall').click() + const regularDialog = page.getByRole('dialog').filter({ hasText: '卸载扩展?' }) + await expect(regularDialog.getByRole('button', { name: '取消', exact: true })).toBeFocused() + await regularDialog.getByRole('button', { name: '卸载', exact: true }).click() + await expect(page.locator('[data-extension-id="tests.settings"]')).toHaveCount(0) + expect(JSON.parse(await fs.readFile(path.join(instance.home, 'buddy/extensions/data/tests.settings/configuration.json'), 'utf8'))).toMatchObject({ enabled: true, inline: true }) + await openSettings(page) + await expect(page.getByRole('link', { name: '注册设置', exact: true })).toHaveCount(0) + await page.getByRole('link', { name: '常规', exact: true }).click() + await expect(page.locator('[data-setting-id="tests.settings.inline"]')).toHaveCount(0) + await expect(page.getByTestId('context-panel-global-setting')).toBeVisible() + + async function reinstall() { + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + }, directory) + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.getByTestId('extension-install-options').click() + await page.getByTestId('extension-development').click() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list()).map(plugin => plugin.manifest.id))).toContain('tests.settings') + await expect(page.locator('[data-extension-id="tests.settings"]')).toBeVisible() + } + const card = () => page.locator('[data-extension-id="tests.settings"]') + await reinstall() + await card().getByRole('button', { name: '打开', exact: true }).click() + await expect(page).toHaveURL(/#\/settings\/plugins\/tests.settings.module$/) + await expect(toggle()).toBeChecked() + await page.evaluate(() => window.lexoraDesktop.extensions.execute('tests.settings', 'tests.settings.open', null)) + await expect.poll(() => page.evaluate(async () => Object.values((await window.lexoraDesktop.workbench.read()).layout.views).filter(view => view.resource.data.extensionId === 'tests.settings').length)).toBe(1) + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + const showUninstall = async () => { + await card().getByTestId('extension-card-more').click() + await page.getByTestId('extension-uninstall').click() + } + await showUninstall() + const uninstallDialog = () => page.getByRole('dialog').filter({ hasText: '卸载扩展?' }) + await expect(uninstallDialog()).toContainText('无法撤销') + await expect(uninstallDialog().getByRole('button', { name: '取消', exact: true })).toBeFocused() + const cleanBounds = await uninstallDialog().getByRole('button', { name: '卸载并清理', exact: true }).boundingBox() + const cancelBounds = await uninstallDialog().getByRole('button', { name: '取消', exact: true }).boundingBox() + expect(cleanBounds.x).toBeLessThan(cancelBounds.x) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'uninstall-options.png'), animations: 'disabled' }) + await uninstallDialog().getByRole('button', { name: '取消', exact: true }).press('Enter') + await expect(uninstallDialog()).toHaveCount(0) + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ enabled: true }) + await app.evaluate(({ ipcMain }) => { + const write = ipcMain._invokeHandlers.get('lexora:workbench:write') + globalThis.failCleanupCheckpoint = true + ipcMain.removeHandler('lexora:workbench:write') + ipcMain.handle('lexora:workbench:write', (event, state, options) => { + if (options?.resetRecovery && globalThis.failCleanupCheckpoint) + throw new Error('Isolated cleanup persistence failure') + return write(event, state, options) + }) + }) + await showUninstall() + await uninstallDialog().getByRole('button', { name: '卸载并清理', exact: true }).click() + await expect(page.getByText('操作未完成: EXTENSION_DATA_CLEANUP_FAILED', { exact: true })).toBeVisible() + await expect(uninstallDialog()).toBeVisible() + expect(JSON.parse(await fs.readFile(path.join(instance.home, 'buddy/extensions/data/tests.settings/state.json'), 'utf8'))).toMatchObject({ value: { retained: true } }) + await app.evaluate(() => globalThis.failCleanupCheckpoint = false) + await uninstallDialog().getByRole('button', { name: '卸载并清理', exact: true }).click() + await expect(card()).toHaveCount(0) + await expect(uninstallDialog()).toHaveCount(0) + await expect(fs.stat(path.join(instance.home, 'buddy/extensions/data/tests.settings'))).rejects.toMatchObject({ code: 'ENOENT' }) + for (const filename of ['workbench.json', 'workbench.previous.json']) { + const snapshot = JSON.parse(await fs.readFile(path.join(instance.home, 'buddy', filename), 'utf8')) + expect(Object.values(snapshot.layout.views).filter(view => view.resource.data.extensionId === 'tests.settings')).toEqual([]) + expect(snapshot.configuration['workbench.controls.model.reasoning']).toBeUndefined() + expect(JSON.parse(snapshot.configuration['workbench.slots.composer.accessory'])).toEqual(['tests.settings-other.retained']) + } + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + await reinstall() + await card().getByRole('button', { name: '打开', exact: true }).click() + await expect(toggle()).not.toBeChecked() + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ enabled: false, inline: false }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + +test('scoped wildcard observers coexist with explicit configuration application and recover incompatible upgrades', async ({ buddy }) => { + const instance = await buddy.createInstance('settings-events') + let { app, page, diagnostics } = await instance.launch() + const directory = path.join(instance.home, 'event-plugin') + await writeSettingsPlugin(directory) + await fs.writeFile(path.join(directory, 'extension.js'), ` +export function activate(context) { + let once = 0; + let aliases = 0; + context.events.on(['configuration:*', 'configuration:changed'], () => { once++; }, { once: true }); + context.configuration.onChange(() => { aliases++; }); + const stopped = new AbortController(); + context.events.on('configuration:**', () => { throw new Error('disposed listener'); }, { signal: stopped.signal }); + stopped.abort(); + context.events.on('configuration:**', async event => { + await Promise.resolve(); + await context.storage.set({ once, aliases, configuration: event.data.configuration, changedKeys: event.data.changedKeys }); + await context.views.broadcast({ label: event.data.configuration.label }); + }); + context.commands.register('tests.settings.open', () => context.views.open('tests.settings.view', { state: {} })); +}`) + await fs.writeFile(path.join(directory, 'view.js'), ` +export function render(context, container) { + container.dataset.instance = crypto.randomUUID(); + const output = document.createElement('output'); + output.textContent = 'waiting'; + container.append(output); + let deliveries = 0; + context.events.on(['view:**', 'view:message:received'], event => { + if (event.type === 'view:message:received') { + output.textContent = event.data.message.label; + container.dataset.deliveries = String(++deliveries); + } + }); +}`) + async function install(updating = false) { + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + }, directory) + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.getByTestId('extension-install-options').click() + await page.getByTestId('extension-development').click() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async (updating) => { + const plugin = (await window.lexoraDesktop.extensions.list())[0] + return !!plugin && (!updating || !!plugin.pending) + }, updating)).toBe(true) + } + await install() + await page.evaluate(() => window.lexoraDesktop.extensions.execute('tests.settings', 'tests.settings.open', null)) + await expect.poll(() => page.frames().some(frame => frame.url().includes('/__view.html'))).toBe(true) + const frame = page.frames().find(frame => frame.url().includes('/__view.html')) + await expect(frame.locator('output')).toHaveText('waiting') + const viewInstance = await frame.locator('main').getAttribute('data-instance') + const generation = await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].generation) + await openSettings(page) + await page.getByRole('link', { name: '注册设置', exact: true }).click() + const label = page.locator('[data-setting-id="tests.settings.label"] input') + for (const value of ['first', 'retained']) { + await expect(label).toBeEnabled() + await label.fill(value) + await label.press('Enter') + await expect(frame.locator('output')).toHaveText(value) + await expect(label).toBeEnabled() + } + expect(await frame.locator('main').getAttribute('data-instance')).toBe(viewInstance) + expect(await frame.locator('main').getAttribute('data-deliveries')).toBe('2') + expect(await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].generation)).toBe(generation) + const stored = JSON.parse(await fs.readFile(path.join(instance.home, 'buddy/extensions/data/tests.settings/state.json'), 'utf8')) + expect(stored.value).toMatchObject({ once: 1, aliases: 2, configuration: { label: 'retained' }, changedKeys: ['label'] }) + await page.evaluate(() => window.lexoraDesktop.extensions.configure('tests.settings', { extra: 56 })) + const manifestPath = path.join(directory, 'extension.json') + const upgraded = JSON.parse(await fs.readFile(manifestPath, 'utf8')) + upgraded.version = '1.1.0' + upgraded.contributes.settings.items.find(item => item.key === 'extra').max = 10 + await fs.writeFile(manifestPath, JSON.stringify(upgraded)) + await install(true) + await page.evaluate(() => window.lexoraDesktop.extensions.restart('tests.settings')) + await openSettings(page) + const number = page.locator('[data-setting-id="tests.settings.extra-item"]') + await expect(number.getByRole('status')).toContainText('原值已保留') + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configurationSnapshot('tests.settings'))).toMatchObject({ values: { extra: 56, label: 'retained' }, invalidKeys: ['extra'] }) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'configuration-upgrade-recovery.png'), animations: 'disabled' }) + await number.getByRole('button', { name: '恢复默认值', exact: true }).click() + await expect(number.getByRole('status')).toHaveCount(0) + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ extra: 0, label: 'retained' }) + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configurationSnapshot('tests.settings'))).toMatchObject({ values: { extra: 0, label: 'retained' }, invalidKeys: [] }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + +async function openSettings(page) { + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '设置', exact: true }).click() + await page.locator('.desktop-settings-sidebar').getByRole('link', { name: '常规', exact: true }).click() + await expect(page).toHaveURL(/#\/settings\/general$/) +} +function panelSettings(page) { + return page.evaluate(async () => { + const { desktop } = await window.lexoraDesktop.settings.get() + return { global: desktop.contextPanelGlobal, mode: desktop.contextPanelMode } + }) +} +async function writeSettingsPlugin(directory) { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, 'extension.json'), JSON.stringify({ + schemaVersion: 1, + id: 'tests.settings', + name: '注册设置', + version: '1.0.0', + apiVersion: 3, + engines: { lexora: '*' }, + entry: 'extension.js', + contributes: { commands: [{ id: 'tests.settings.open', title: 'Open fixture' }], views: [{ id: 'tests.settings.view', title: 'Fixture view', resource: 'none', entry: 'view.js' }], settings: { + modules: [{ id: 'tests.settings.module', title: '注册设置' }], + groups: [ + { id: 'tests.settings.group', module: 'tests.settings.module', title: '插件分组' }, + { id: 'tests.settings.extra', module: 'settings.general', title: '附加分组', order: 5 }, + { id: 'tests.settings.logs', module: 'settings.logs', title: '日志附加分组' }, + ], + items: [ + { id: 'tests.settings.enabled', key: 'enabled', group: 'tests.settings.group', type: 'boolean', title: '启用', default: false }, + { id: 'tests.settings.label', key: 'label', group: 'tests.settings.group', type: 'string', title: '名称', default: '' }, + { id: 'tests.settings.inline', key: 'inline', group: 'settings.general.general', type: 'boolean', title: '分组内单项', default: false }, + { id: 'tests.settings.extra-item', key: 'extra', group: 'tests.settings.extra', type: 'number', title: '数值', default: 0 }, + { id: 'tests.settings.logs-item', key: 'log', group: 'tests.settings.logs', type: 'boolean', title: '日志附加项', default: false }, + ], + } }, + })) + await fs.writeFile(path.join(directory, 'extension.js'), `export function activate(context) { context.commands.register('tests.settings.open', async () => { await context.storage.set({ retained: true }); await context.views.open('tests.settings.view', { state: { position: 17 } }); }); }\n`) + await fs.writeFile(path.join(directory, 'view.js'), 'export function render(context, container) { container.textContent = "Fixture view"; }\n') +} diff --git a/.playwright/scripts/__tests__/workbench.e2e.mjs b/.playwright/scripts/__tests__/workbench.e2e.mjs new file mode 100644 index 00000000..ea4b7824 --- /dev/null +++ b/.playwright/scripts/__tests__/workbench.e2e.mjs @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +test('builtin working copies back up continued edits, veto closure and recover across restart without plugins', async ({ buddy }) => { + const instance = await buddy.createInstance('working-copy') + const directory = path.join(instance.home, 'documents') + const document = path.join(directory, 'README.md') + await fs.mkdir(directory) + await fs.writeFile(document, 'Original document.\n') + let { app, page, diagnostics } = await instance.launch() + expect(await page.evaluate(() => window.lexoraDesktop.extensions.list())).toEqual([]) + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + }, directory) + const section = page.locator('.desktop-task-sidebar__spaces') + await section.locator('.desktop-task-sidebar__section-heading').hover() + await section.locator('.desktop-task-sidebar__section-add').click() + const spaceDialog = page.locator('.desktop-space-dialog') + await spaceDialog.getByPlaceholder('输入空间名称').fill('工作副本验收') + await spaceDialog.getByRole('button', { name: '选择目录', exact: true }).click() + await expect(spaceDialog.locator('.desktop-space-primary-directory input')).toHaveValue(directory) + await spaceDialog.getByRole('button', { name: '确定', exact: true }).click() + await expect(spaceDialog).toBeHidden() + const space = page.locator('.desktop-task-space-row').filter({ hasText: '工作副本验收' }) + await space.hover() + await space.getByRole('button', { name: '更多操作', exact: true }).click() + await page.locator('.n-dropdown-menu:visible').getByText('新任务', { exact: true }).click() + await page.locator('.desktop-workbench-area__tasks .tiptap').click() + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('文件') + await page.getByPlaceholder('输入命令名称').press('Enter') + await expect(page.locator('.n-modal')).toBeHidden() + await page.getByTestId('context-file-tree').getByText('README.md', { exact: true }).click() + await page.getByTestId('document-mode-edit').click() + let editor = page.getByTestId('workbench-text-editor') + await expect(editor.locator('.view-lines')).toContainText('Original document.') + await editor.locator('.view-lines').click() + await page.keyboard.press('Control+End') + await page.keyboard.insertText('First edit.\n') + await expect(page.locator('.file-editor[data-dirty="true"]')).toBeVisible() + await page.keyboard.insertText('Second edit while dirty.\n') + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.some(backup => backup.text.includes('Second edit while dirty.')))).toBe(true) + await expect.poll(() => page.evaluate(async () => Object.values((await window.lexoraDesktop.workbench.read())?.layout.views ?? {}).some(view => view.type === 'files.editor' && view.state.editor?.cursorState?.[0]?.position.lineNumber === 4))).toBe(true) + const readBackup = () => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.find(backup => backup.resource.data.path === 'README.md')?.text) + const beforeUndo = await readBackup() + await page.keyboard.press('Control+z') + await expect.poll(readBackup).not.toBe(beforeUndo) + await page.keyboard.press('Control+y') + await expect.poll(readBackup).toBe(beforeUndo) + await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.') + expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n') + await page.getByTestId('task-context-panel').getByRole('button', { name: '关闭文件', exact: true }).click() + const close = page.locator('.n-dialog').filter({ hasText: '关闭前保存更改?' }) + await close.getByRole('button', { name: '取消', exact: true }).click() + await expect(close).toBeHidden() + await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.') + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + const panelToggle = page.getByTestId('context-panel-toggle') + if (await panelToggle.getAttribute('aria-expanded') === 'false') + await panelToggle.click() + editor = page.getByTestId('workbench-text-editor') + await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.') + await expect(page.locator('.file-editor[data-dirty="true"]')).toBeVisible() + expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n') + await editor.locator('.view-lines').click() + await page.keyboard.press('Control+s') + await expect(page.locator('.file-editor[data-dirty="false"]')).toBeVisible() + await expect.poll(() => fs.readFile(document, 'utf8')).toContain('Second edit while dirty.') + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.length)).toBe(0) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovered-and-saved.png'), animations: 'disabled' }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts index ab81f15b..50a3bc36 100644 --- a/apps/buddy/electron.vite.config.ts +++ b/apps/buddy/electron.vite.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath, URL } from 'node:url' import vue from '@vitejs/plugin-vue' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import UnoCSS from 'unocss/vite' +import { extensionRuntimePlugin } from './electron/extensionRuntimePlugin.ts' const PRODUCTION_CONNECT_SRC = 'connect-src \'self\';' const DEVELOPMENT_CONNECT_SRC @@ -33,7 +34,7 @@ const electronCacheRoot = fileURLToPath( export default defineConfig({ main: { cacheDir: join(electronCacheRoot, 'main'), - plugins: [externalizeDepsPlugin({ exclude: ['typescript'] })], + plugins: [extensionRuntimePlugin(), externalizeDepsPlugin({ exclude: ['typescript'] })], build: { outDir: join(electronOutputRoot, 'main'), rollupOptions: { diff --git a/apps/buddy/electron/extensionRuntimePlugin.ts b/apps/buddy/electron/extensionRuntimePlugin.ts new file mode 100644 index 00000000..a6d0b25a --- /dev/null +++ b/apps/buddy/electron/extensionRuntimePlugin.ts @@ -0,0 +1,31 @@ +import type { Plugin } from 'vite' +import { fileURLToPath } from 'node:url' +import { build } from 'vite' + +export function extensionRuntimePlugin(): Plugin { + const prefix = 'virtual:extension-runtime/' + return { + name: 'lexora-extension-runtime', + resolveId(id) { + if (id === `${prefix}host` || id === `${prefix}view`) + return `\0${id}` + }, + async load(id) { + if (!id.startsWith(`\0${prefix}`)) + return + const kind = id.slice(prefix.length + 1) + const result = await build({ + configFile: false, + envFile: false, + logLevel: 'silent', + build: { write: false, minify: false, target: 'esnext', lib: { entry: fileURLToPath(new URL(`./main/extensions/runtime/${kind}.js`, import.meta.url)), formats: ['es'] } }, + }) + const chunks = (Array.isArray(result) ? result.flatMap(item => item.output) : 'output' in result ? result.output : []).filter(item => item.type === 'chunk') + if (chunks.length !== 1 || chunks[0]!.imports.length) + throw new Error('Extension runtime must be a self-contained browser module') + for (const path of Object.keys(chunks[0]!.modules)) + this.addWatchFile(path) + return `export default ${JSON.stringify(chunks[0]!.code)}` + }, + } +} diff --git a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts index 7cc98dd0..9d3f1e2a 100644 --- a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts +++ b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts @@ -10,6 +10,10 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { PrivateDirectoryError } from '../../../platform/windows/privateDirectories' import { ServiceHost } from '../../../shared/lifecycle/ServiceHost' import { ApplicationEvents } from '../../../shared/observability/ApplicationEvents' +import { observeLifecycleDiagnostics } from '../../../shared/observability/lifecycleDiagnostics' +import { closeDesktopDiagnostics } from '../app/closeDesktopDiagnostics' +import { DesktopStartup } from '../app/DesktopStartup' +import { observeStartupDiagnostics } from '../app/startupDiagnostics' import { DesktopDiagnosticLogger } from '../desktopDiagnostics' import { ApplicationLogReader } from '../diagnostics/ApplicationLogReader' import { DiagnosticFile } from '../diagnostics/diagnosticFile' @@ -52,7 +56,8 @@ describe('desktop diagnostics', () => { const { directory, logger } = await createLogger() const events = new ApplicationEvents() events.subscribe(event => logger.record({ ...event, scope: 'desktop' })) - const host = new ServiceHost(events) + const host = new ServiceHost() + observeLifecycleDiagnostics(host.lifecycle, events) const failure = { kind: 'private_directories', operation: 'open_directory', directoryRole: 'session_data', systemError: { domain: 'ntstatus', code: 0xC0000022 }, exitCode: 1 } as const const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_FAILED', failure, { cause: new Error('token=fixture-secret') }) await expect(host.step('desktop.environment', () => { @@ -67,6 +72,37 @@ describe('desktop diagnostics', () => { expect(page.records[0]).toMatchObject({ failure }) }) + it('records the final cleanup result before closing the logger', async () => { + const { directory, logger } = await createLogger() + const events = new ApplicationEvents() + const startup = new DesktopStartup() + const host = new ServiceHost() + events.subscribe(event => logger.record({ ...event, scope: 'desktop' })) + startup.bindDesktop(host.lifecycle) + observeLifecycleDiagnostics(host.lifecycle, events) + observeStartupDiagnostics(startup, events) + await host.start('desktop', ({ defer }) => { + defer(() => { + throw new Error('fixture-private-cleanup') + }) + }) + startup.stopping() + try { + await host.stop() + } + catch (error) { + startup.stopped(error) + } + await closeDesktopDiagnostics(logger) + const records = await readRecords(directory) + expect(records.at(-1)?.event).toBe('app.stop_failed') + expect(records.filter(record => record.event === 'app.stop_failed')).toHaveLength(1) + expect(records.some(record => record.event === 'component.stop_failed')).toBe(true) + expect(JSON.stringify(records)).not.toContain('fixture-private-cleanup') + expect(logger.status).toMatchObject({ state: 'closed', unconfirmed: 0, dropped: 0, failed: 0 }) + expect(startup.state.status).toBe('stopped') + }) + it('retains run and turn identities while excluding arbitrary operation details', async () => { const { directory, logger } = await createLogger() const input = { @@ -213,6 +249,18 @@ describe('desktop diagnostics', () => { }) }) + it('reports bounded close loss once through stderr without writing back to the closed sink', async () => { + const { directory, logger } = await createLogger() + logger.record({ ...event, message: 'fixture-private'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) }) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + await closeDesktopDiagnostics(logger) + await closeDesktopDiagnostics(logger) + expect(stderr).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(stderr.mock.calls[0]![0]))).toEqual({ event: 'recorder.close_incomplete', dropped: 1, failed: 0, unconfirmed: 0, closeTimedOut: false, ioFailed: false }) + expect((await readRecords(directory)).map(record => record.event)).toEqual(['recorder.loss']) + expect(logger.status).toMatchObject({ state: 'closed', accepted: 1, written: 1, dropped: 1 }) + }) + it('bounds file size and count while retaining the latest complete records', async () => { const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 3 }) for (let index = 0; index < 8; index++) { diff --git a/apps/buddy/electron/main/app/DesktopApplication.ts b/apps/buddy/electron/main/app/DesktopApplication.ts index 3c204c14..481331f7 100644 --- a/apps/buddy/electron/main/app/DesktopApplication.ts +++ b/apps/buddy/electron/main/app/DesktopApplication.ts @@ -7,8 +7,11 @@ import { localTransports } from '../../../platform/ipc/localTransport' import { DEFAULT_BROWSER_PREFERENCES } from '../../../shared/browser/browserPreferences' import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' import { ServiceHost } from '../../../shared/lifecycle/ServiceHost' +import { ServiceLifecycleSource } from '../../../shared/lifecycle/ServiceLifecycleSource' +import { observeLifecycleDiagnostics } from '../../../shared/observability/lifecycleDiagnostics' import { BrowserIntegration } from '../browser/BrowserIntegration' import { resolveDesktopLaunchIntent } from '../startupIntent' +import { closeDesktopDiagnostics } from './closeDesktopDiagnostics' import { confirmDesktopQuit, showBackgroundCloseNotice, showDesktopStartupFailure, showLegacyPowerShellNotice } from './desktopDialogs' import { DesktopIntegrations } from './DesktopIntegrations' import { describeProcessExit } from './desktopProcessDiagnostics' @@ -27,15 +30,21 @@ class DesktopApplication { readonly #runtime: DesktopRuntimeHost readonly #integrations: DesktopIntegrations readonly #host: ServiceHost + readonly #lifecycleSubscriptions: (() => void)[] readonly #quit: ReturnType #disposePromise: Promise | null = null #rendererRecoveryPrompt: Promise | null = null constructor(environment: DesktopEnvironment) { this.#environment = environment - this.#host = new ServiceHost(environment.events) + this.#host = new ServiceHost(new ServiceLifecycleSource(() => environment.events.publish({ event: 'observer.failed', component: 'desktop.lifecycle', level: 'warn' }))) + this.#lifecycleSubscriptions = [ + environment.startup.bindDesktop(this.#host.lifecycle), + observeLifecycleDiagnostics(this.#host.lifecycle, environment.events), + ] this.#windows = new DesktopWindowHost(environment) this.#browser = new BrowserIntegration({ + report: event => environment.events.publish(event), isTaskLinked: () => this.#runtime.config?.desktop.contextPanelMode === 'task', onActivityError: () => environment.events.publish({ level: 'warn', event: 'browser.activity.failed', errorCode: 'BROWSER_ACTIVITY_FAILED' }), endpoint: localTransports[currentPlatform.transport](environment.paths.browserAdapterSocket), @@ -53,7 +62,7 @@ class DesktopApplication { try { await this.#dispose() } - finally { await environment.diagnostics.close() } + finally { await closeDesktopDiagnostics(environment.diagnostics) } }, quit: (restart) => { if (restart) @@ -75,8 +84,9 @@ class DesktopApplication { nativeTheme.on('updated', () => this.#windows.updateAppearance()) process.once('SIGINT', () => { void this.#quit.request({ discardDraftsOnFailure: true }).catch(async (error) => { - this.#environment.events.publish({ level: 'error', event: 'app.interrupt_failed', errorCode: readDiagnosticErrorCode(error) }) - await this.#environment.diagnostics.close() + if (!this.#quit.quitting) + this.#environment.events.publish({ level: 'error', event: 'app.interrupt_failed', errorCode: readDiagnosticErrorCode(error) }) + await closeDesktopDiagnostics(this.#environment.diagnostics) app.exit(1) }) }) @@ -150,7 +160,7 @@ class DesktopApplication { } finally { try { - await this.#environment.diagnostics.close() + await closeDesktopDiagnostics(this.#environment.diagnostics) } finally { app.exit(1) } } @@ -159,7 +169,8 @@ class DesktopApplication { #requestQuit(): void { void this.#quit.request().catch((error) => { - this.#environment.events.publish({ level: 'error', event: 'app.stop_failed', errorCode: readDiagnosticErrorCode(error) }) + if (!this.#quit.quitting) + this.#environment.events.publish({ level: 'error', event: 'app.stop_failed', errorCode: readDiagnosticErrorCode(error) }) if (this.#quit.quitting) app.exit(1) }) @@ -167,7 +178,8 @@ class DesktopApplication { #requestRestart(): void { void this.#quit.request({ restart: true }).catch((error) => { - this.#environment.events.publish({ level: 'error', event: 'app.restart_failed', errorCode: readDiagnosticErrorCode(error) }) + if (!this.#quit.quitting) + this.#environment.events.publish({ level: 'error', event: 'app.restart_failed', errorCode: readDiagnosticErrorCode(error) }) if (this.#quit.quitting) app.exit(1) }) @@ -196,7 +208,6 @@ class DesktopApplication { #dispose(): Promise { this.#disposePromise ??= (async () => { - this.#environment.events.publish({ level: 'info', event: 'app.stopping' }) this.#environment.startup.stopping() const failures: unknown[] = [] try { @@ -205,10 +216,12 @@ class DesktopApplication { catch (error) { failures.push(error) } - this.#environment.events.publish({ level: failures.length ? 'error' : 'info', event: failures.length ? 'app.stop_failed' : 'app.stopped' }) - this.#environment.startup.stopped() - if (failures.length) - throw new AggregateError(failures, 'Desktop application cleanup failed') + const failure = failures.length ? new AggregateError(failures, 'Desktop application cleanup failed') : undefined + this.#environment.startup.stopped(failure) + for (const stop of this.#lifecycleSubscriptions) + stop() + if (failure) + throw failure })() return this.#disposePromise } @@ -223,7 +236,7 @@ export async function startDesktopApplication(): Promise { if (!app.requestSingleInstanceLock()) { environment.diagnostics.record({ scope: 'desktop', level: 'info', event: 'startup.single_instance_lock_unavailable' }) await checkDesktopCoreDirectories(environment) - await environment.diagnostics.close() + await closeDesktopDiagnostics(environment.diagnostics) app.quit() return } @@ -245,7 +258,8 @@ export async function startDesktopApplication(): Promise { const failedEnvironment = environment void showDesktopStartupFailure(error, 'zh-CN', environment, failedEnvironment ? () => prepareDesktopReady(failedEnvironment) : undefined).finally(async () => { try { - await environment?.diagnostics.close() + if (environment) + await closeDesktopDiagnostics(environment.diagnostics) } finally { app.exit(1) diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index e3562e3f..cb227615 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -10,8 +10,11 @@ import process from 'node:process' import { app, Notification, shell } from 'electron' import { z } from 'zod' import buddyVersion from '../../../buddy.version.json' +import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent' +import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring' import { spaceTextDocumentSchema } from '../../../shared/spaces/spaceFileApi' +import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { registerBrowserDesktopIpc } from '../browser/registerBrowserDesktopIpc' import { registerContextPanelIpc } from '../context-panel/registerContextPanelIpc' import { createDesktopCommandExecutor } from '../desktopCommands' @@ -74,23 +77,28 @@ export class DesktopIntegrations { const windows = this.#windows const service = runtime.service const extensions = registerExtensionIpc({ + record: this.#environment.events.publish, home: paths.buddyHome, version: buddyVersion.version, developmentDirectory: !app.isPackaged && paths.profile === 'test' ? process.env.LEXORA_EXTENSION_DEVELOPMENT_PATH : undefined, getWindow: () => windows.window, get: runtime.network.get, notificationsEnabled: () => runtime.config?.desktop.notificationsEnabled ?? true, + agentChanged: () => service.notify(extensionAgentRpc.changed, {}), + agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: 120000 })), readText: async (target, signal) => spaceTextDocumentSchema.parse(await service.request('spaceFiles.readDocument', target, { signal })).text, }) this.#subscriptions.push(extensions.dispose) runtime.inspectExtension = extensions.inspect + runtime.extensionAgent = extensions.agent this.#subscriptions.push(() => { runtime.inspectExtension = null + runtime.extensionAgent = null }) this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window)) this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window)) - this.#subscriptions.push(registerStartupIpc(this.#environment.startup, () => windows.window, this.#environment.events)) - this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window)) + this.#subscriptions.push(registerStartupIpc(this.#environment.startup, () => windows.window)) + this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window, event => diagnostics.record(event))) this.#tray = createDesktopTray({ appName: paths.appName, iconPath: trayIconPath, @@ -141,6 +149,11 @@ export class DesktopIntegrations { openFeedbackIssue: feedback => shell.openExternal(createFeedbackIssueUrl(feedback)), openReleasePage: url => shell.openExternal(url), }) + const configNotifications = runtime.configStore.onDidChange((change) => { + if (change.kind === 'committed' && windows.window && !windows.window.isDestroyed()) + windows.window.webContents.send(DESKTOP_IPC_CHANNELS.settingsChanged, change.config) + }) + this.#subscriptions.push(() => configNotifications.dispose()) this.#subscriptions.push(registerBrowserDesktopIpc({ data: this.#browser.data, screenshots: this.#browser.screenshots, diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 2f2e2635..58efc2b9 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -1,6 +1,7 @@ import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' import type { LexoraConfig } from '../../shared/desktopApi' import type { BrowserIntegration } from '../browser/BrowserIntegration' +import type { registerExtensionIpc } from '../extensions/registerExtensionIpc' import type { DesktopFeature } from '../platform/desktopFeatures' import type { CredentialVault } from '../secrets/CredentialVault' import type { DesktopWindowHost } from './DesktopWindowHost' @@ -17,7 +18,7 @@ import { currentTarget } from '../../../platform/target' import { resolveWindowsPowerShell } from '../../../platform/windows/powerShell' import { automationNotifications } from '../../../shared/automation/automationApi' import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel' -import { readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic' +import { extensionAgentInvocationSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent' import { isLinux } from '../../../shared/platform/identifiers' import { runtimePreferencesRpc } from '../../../shared/runtime/runtimePreferences' import { installAttachmentProtocol } from '../attachmentProtocol' @@ -39,6 +40,7 @@ import { registerCredentialHostRpc } from '../secrets/registerCredentialHostRpc' export class DesktopRuntimeHost { inspectExtension: ((id: string) => Promise) | null = null + extensionAgent: ReturnType['agent'] | null = null readonly contextPanel: ContextPanelHost readonly configStore: LexoraConfigStore readonly #environment: DesktopEnvironment @@ -59,14 +61,13 @@ export class DesktopRuntimeHost { this.#windows = windows this.#browser = browser this.configStore = new LexoraConfigStore({ configPath: environment.paths.configPath }) + const configDiagnostics = this.configStore.onDidChange(change => environment.events.publish({ event: `settings.${change.kind.replaceAll('-', '_')}`, component: 'desktop.settings', level: change.kind.endsWith('failed') ? 'warn' : 'info', revision: change.revision, operationId: change.operationId, count: change.groups.length })) + this.#subscriptions.push(() => configDiagnostics.dispose()) this.contextPanel = new ContextPanelHost(async (operation) => { - try { - await this.service.request(contextPanelRpc.recordOperation, operation) - } - catch (error) { - environment.diagnostics.record({ scope: 'desktop', level: 'warn', event: 'context_panel.record.failed', error }) - } + await this.service.request(contextPanelRpc.recordOperation, operation) }) + const panelDiagnostics = this.contextPanel.onDidChange(change => environment.events.publish({ event: change.kind === 'record' ? `context_panel.record.${change.status}` : `context_panel.${change.state.open ? 'opened' : 'closed'}`, component: 'desktop.context_panel', level: change.kind === 'record' && change.status === 'failed' ? 'warn' : 'info', operationId: change.operationId, revision: change.revision })) + this.#subscriptions.push(() => panelDiagnostics.dispose()) } get config(): LexoraConfig | null { @@ -141,15 +142,16 @@ export class DesktopRuntimeHost { const config = await this.configStore.read() this.#config = config this.#network = new DesktopNetwork() + const networkDiagnostics = this.#network.onDidChange(change => environment.events.publish({ event: `network.${change.kind}.${change.status}`, component: 'desktop.network', level: ['failed', 'degraded', 'unavailable'].includes(change.status) ? 'warn' : 'info', operationId: change.operationId, revision: change.revision, ...(change.failure ? { failure: change.failure, errorCode: 'NETWORK_START_FAILED' } : {}) })) + this.#subscriptions.push(() => networkDiagnostics.dispose()) await this.#network.start(config.proxy) - if (this.#network.startupError) - environment.events.publish({ event: 'network.start_failed', component: 'desktop.network', level: 'warn', ...readDiagnosticError(this.#network.startupError) }) this.#windowsPowerShell = currentPlatform.shell === 'powershell' ? await resolveWindowsPowerShell() : undefined const composition = createDesktopFeatures(currentPlatform, { ...nativePaths, diagnostics: environment.diagnostics, + report: event => environment.events.publish(event), onOpenDesktop: () => this.#windows.show(), paths: environment.paths, }) @@ -157,10 +159,15 @@ export class DesktopRuntimeHost { this.#service = new BuddyServiceSupervisor({ onDiagnostic: (event) => { environment.diagnostics.record({ ...event, scope: 'local-service' }) - environment.startup.observe(event, event) }, bindPeer: (peer) => { const disposers = [ + peer.onRequest(extensionAgentRpc.list, () => this.extensionAgent?.list() ?? []), + peer.onRequest(extensionAgentRpc.invoke, (input, signal) => { + if (!this.extensionAgent) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + return this.extensionAgent.invoke(extensionAgentInvocationSchema.parse(input), signal ?? new AbortController().signal) + }), peer.onRequest(runtimePreferencesRpc.get, () => this.#config!.runtime), registerExtensionAuthoringRpc(peer, (id) => { if (!this.inspectExtension) @@ -199,6 +206,7 @@ export class DesktopRuntimeHost { captureStderr: output => environment.diagnostics.captureOutput('local-service', output, sourceId), }), }) + this.#subscriptions.push(environment.startup.bindRuntime(this.#service)) return config } @@ -246,6 +254,8 @@ export class DesktopRuntimeHost { async stop(): Promise { const failures: unknown[] = [] for (const cleanup of [ + () => this.contextPanel.dispose(), + () => this.configStore.dispose(), () => this.#service?.stop(), () => this.#network?.stop(), ...this.#features.map(feature => () => feature.stop()), diff --git a/apps/buddy/electron/main/app/DesktopStartup.ts b/apps/buddy/electron/main/app/DesktopStartup.ts index ed5131c4..13b6f751 100644 --- a/apps/buddy/electron/main/app/DesktopStartup.ts +++ b/apps/buddy/electron/main/app/DesktopStartup.ts @@ -1,23 +1,48 @@ -import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic' import type { ApplicationStartupState } from '../../../shared/diagnostics/applicationStartup' -import type { ApplicationEvents } from '../../../shared/observability/ApplicationEvents' -import { readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic' +import type { Event, ListenerErrorHandler } from '../../../shared/events/Emitter' +import type { LifecycleFailure } from '../../../shared/lifecycle/lifecycleFailure' +import type { RuntimeLifecycleReader, RuntimeLifecycleSnapshot } from '../../../shared/lifecycle/runtimeLifecycle' +import type { RendererLifecycleReport, ServiceLifecycleReader, ServiceLifecycleSnapshot } from '../../../shared/lifecycle/serviceLifecycle' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { readLifecycleFailure } from '../../../shared/lifecycle/lifecycleFailure' + +type StartupTransition = 'ready' | 'recovered' | 'start_failed' | 'degraded' | 'start_cancelled' | 'stopped' | 'stop_failed' +export interface StartupChange { + readonly state: ApplicationStartupState + readonly transition?: StartupTransition + readonly component?: string + readonly operationId?: string + readonly durationMs?: number + readonly failure?: LifecycleFailure +} const REQUIRED_HOSTS = ['desktop', 'runtime.connection', 'renderer'] as const const COMPONENT_STATUSES = { - 'startup.step.started': 'running', - 'startup.step.completed': 'completed', - 'startup.step.failed': 'failed', - 'component.registered': 'pending', - 'component.starting': 'running', - 'component.ready': 'completed', - 'component.start_failed': 'failed', + registered: 'pending', + starting: 'running', + ready: 'completed', + start_failed: 'failed', + stopping: 'stopping', + stopped: 'stopped', + stop_failed: 'failed', } as const export class DesktopStartup { - readonly #events: ApplicationEvents + readonly #changes: Emitter + readonly #rendererReports: Emitter + readonly onDidChange: Event + readonly onDidReportRenderer: Event + readonly #rendererRevisions = new Map() readonly #startedAt = performance.now() - readonly #listeners = new Set<(state: ApplicationStartupState) => void>() + readonly #retiredRenderers = new Set() + #desktopSource: ServiceLifecycleReader | null = null + #runtimeSource: RuntimeLifecycleReader | null = null + #desktop: ServiceLifecycleSnapshot | null = null + #runtime: RuntimeLifecycleSnapshot | null = null + #renderer: ServiceLifecycleSnapshot | null = null + #terminalStatus: 'stopping' | 'stopped' | null = null + #failure: LifecycleFailure | null = null #state: ApplicationStartupState = { revision: 0, generation: null, @@ -26,8 +51,11 @@ export class DesktopStartup { stages: REQUIRED_HOSTS.map(stage => ({ stage, status: 'pending' })), } - constructor(events: ApplicationEvents) { - this.#events = events + constructor(onListenerError: ListenerErrorHandler = () => {}) { + this.#changes = new Emitter(onListenerError) + this.#rendererReports = new Emitter(onListenerError) + this.onDidChange = this.#changes.event + this.onDidReportRenderer = this.#rendererReports.event } get state(): ApplicationStartupState { @@ -35,85 +63,147 @@ export class DesktopStartup { } onStateChange(listener: (state: ApplicationStartupState) => void): () => void { - this.#listeners.add(listener) - return () => this.#listeners.delete(listener) + const subscription = this.onDidChange(change => listener(structuredClone(change.state))) + return () => subscription.dispose() + } + + bindDesktop(source: ServiceLifecycleReader): () => void { + this.#desktopSource = source + const subscription = source.onDidChange(() => this.reconcile()) + this.reconcile() + return () => { + subscription.dispose() + if (this.#desktopSource === source) + this.#desktopSource = null + } + } + + bindRuntime(source: RuntimeLifecycleReader): () => void { + this.#runtimeSource = source + const subscription = source.onDidChangeLifecycle(() => this.reconcile()) + this.reconcile() + return () => { + subscription.dispose() + if (this.#runtimeSource === source) + this.#runtimeSource = null + } + } + + reconcile(): ApplicationStartupState { + if (this.#desktopSource) + this.#desktop = this.#desktopSource.snapshot + if (this.#runtimeSource) { + const runtime = this.#runtimeSource.lifecycleState + if (runtime.generation !== this.#runtime?.generation) { + this.#renderer = null + this.#retiredRenderers.clear() + } + this.#runtime = runtime + } + this.#update() + return this.state + } + + acceptRenderer(report: RendererLifecycleReport): boolean { + const snapshot = report.change.snapshot + if (snapshot.revision <= (this.#rendererRevisions.get(snapshot.sourceId) ?? -1)) + return false + this.#rendererRevisions.set(snapshot.sourceId, snapshot.revision) + if (this.#rendererRevisions.size > 256) + this.#rendererRevisions.delete(this.#rendererRevisions.keys().next().value!) + this.#rendererReports.fire(copyEventSnapshot(report)) + if (report.generation !== this.#runtime?.generation || this.#terminalStatus) + return false + if (this.#retiredRenderers.has(snapshot.sourceId)) + return false + if (this.#renderer?.sourceId === snapshot.sourceId && snapshot.revision <= this.#renderer.revision) + return false + if (this.#renderer && this.#renderer.sourceId !== snapshot.sourceId) { + const root = snapshot.components.find(component => component.component === 'renderer') + if (!root || !['registered', 'starting'].includes(root.status)) + return false + this.#retiredRenderers.add(this.#renderer.sourceId) + } + this.#renderer = copyEventSnapshot(snapshot) + this.#update() + return true } stopping(): void { - if (['stopping', 'stopped'].includes(this.#state.status)) + if (this.#terminalStatus) return const cancelled = this.#state.status === 'starting' && !this.#state.hasBeenReady - this.#state = { ...this.#state, status: 'stopping' } - if (cancelled) - this.#events.publish({ event: 'app.start_cancelled', level: 'info' }) - this.#publishState() + this.#terminalStatus = 'stopping' + this.#publish({ ...this.#state, status: 'stopping' }, cancelled ? { transition: 'start_cancelled' } : {}) } failed(error: unknown): void { - if (['failed', 'stopping', 'stopped'].includes(this.#state.status)) + if (this.#terminalStatus || this.#state.status === 'failed') return - this.#state = { ...this.#state, status: 'failed' } - this.#events.publish({ event: this.#state.hasBeenReady ? 'app.degraded' : 'app.start_failed', level: 'error', ...readDiagnosticError(error) }) - this.#publishState() + this.#failure = readLifecycleFailure(error) + this.#update() } - stopped(): void { - this.#state = { ...this.#state, status: 'stopped' } - this.#publishState() + stopped(error?: unknown): void { + if (this.#terminalStatus === 'stopped') + return + this.#terminalStatus = 'stopped' + this.#publish({ ...this.#state, status: 'stopped' }, error === undefined ? { transition: 'stopped' } : { transition: 'stop_failed', failure: readLifecycleFailure(error) }) } - readonly observe = (event: ApplicationDiagnostic, source?: { sourceId: string }): void => { - if (['runtime.restarting', 'runtime.offline', 'runtime.stopping'].includes(event.event) && !['stopping', 'stopped'].includes(this.#state.status)) { - const failed = event.event === 'runtime.offline' - if (failed && this.#state.status !== 'failed') - this.#events.publish({ event: this.#state.hasBeenReady ? 'app.degraded' : 'app.start_failed', level: 'error', component: 'runtime.connection', parentOperationId: event.operationId, errorCode: event.errorCode, errorType: event.errorType, failure: event.failure }) - this.#state = { ...this.#state, status: failed ? 'failed' : 'starting', stages: this.#state.stages.map(stage => stage.stage === 'runtime.connection' ? { ...stage, status: failed ? 'failed' : 'running', errorCode: event.errorCode } : stage) } - this.#publishState() + #update(): void { + if (this.#terminalStatus) return + const components = [ + ...this.#desktop?.components ?? [], + ...this.#runtime?.services?.components ?? [], + ...this.#renderer?.components ?? [], + ] + const connection = this.#runtime?.connection + if (connection) + components.push(connection) + const stages: ApplicationStartupState['stages'][number][] = components.map(component => ({ + stage: component.component, + status: COMPONENT_STATUSES[component.status], + operationId: component.operationId, + ...(component.durationMs === undefined ? {} : { durationMs: component.durationMs }), + ...(component.failure?.errorCode ? { errorCode: component.failure.errorCode } : {}), + })) + for (const required of REQUIRED_HOSTS) { + if (!stages.some(stage => stage.stage === required)) + stages.push({ stage: required, status: 'pending' }) } - const component = event.component - if (!component || !(event.event in COMPONENT_STATUSES) || ['stopping', 'stopped'].includes(this.#state.status)) - return - const status = COMPONENT_STATUSES[event.event as keyof typeof COMPONENT_STATUSES] - let stages = this.#state.stages - if (component === 'runtime.connection' && status === 'running') { - const generation = event.operationId ?? source?.sourceId ?? null - stages = stages.filter(stage => !stage.stage.startsWith('runtime.') && !stage.stage.startsWith('renderer.')) - .map(stage => stage.stage === 'renderer' ? { stage: stage.stage, status: 'pending' as const } : stage) - this.#state = { ...this.#state, generation } + const runtime = this.#runtime + if (runtime && runtime.status !== 'ready') { + const stage = stages.find(stage => stage.stage === 'runtime.connection')! + stage.status = runtime.status === 'offline' ? 'failed' : runtime.status === 'stopped' ? 'pending' : 'running' + if (runtime.errorCode) + stage.errorCode = runtime.errorCode } - else if (component.startsWith('runtime.') && source?.sourceId !== this.#state.generation) { - return + const failedComponent = components.find(component => component.status === 'start_failed' || component.status === 'stop_failed') + const failed = this.#failure !== null || stages.some(stage => stage.status === 'failed') + const ready = !failed && REQUIRED_HOSTS.every(id => stages.some(stage => stage.stage === id && stage.status === 'completed')) + const status = failed ? 'failed' : ready ? 'ready' : 'starting' + const next: ApplicationStartupState = { ...this.#state, generation: runtime?.generation ?? null, stages, status, hasBeenReady: this.#state.hasBeenReady || ready } + const transition: Omit = {} + if (ready && this.#state.status !== 'ready') { + Object.assign(transition, { transition: this.#state.hasBeenReady ? 'recovered' : 'ready', durationMs: Math.round(performance.now() - this.#startedAt) }) } - if ((component === 'renderer' || component.startsWith('renderer.')) && event.generation !== this.#state.generation) - return - if (component === 'renderer' && status === 'running') - stages = stages.filter(stage => !stage.stage.startsWith('renderer.')) - const previous = stages.find(stage => stage.stage === component) - if (status !== 'pending' && status !== 'running' && previous?.operationId !== event.operationId) - return - const next = { stage: component, status, operationId: event.operationId, durationMs: event.durationMs, errorCode: event.errorCode } - stages = previous ? stages.map(stage => stage === previous ? next : stage) : [...stages, next] - const ready = REQUIRED_HOSTS.every(id => stages.some(stage => stage.stage === id && stage.status === 'completed')) - const failed = stages.some(stage => stage.status === 'failed') - const stateStatus = failed ? 'failed' : ready ? 'ready' : 'starting' - const wasReady = this.#state.hasBeenReady - const previousStatus = this.#state.status - this.#state = { ...this.#state, status: stateStatus, stages, hasBeenReady: wasReady || ready } - if (ready && previousStatus !== 'ready') - this.#events.publish({ event: wasReady ? 'app.recovered' : 'app.ready', level: 'info', durationMs: Math.round(performance.now() - this.#startedAt) }) - else if (stateStatus === 'failed' && previousStatus !== 'failed') - this.#events.publish({ event: wasReady ? 'app.degraded' : 'app.start_failed', level: 'error', component, parentOperationId: event.operationId, errorCode: event.errorCode, errorType: event.errorType, failure: event.failure }) - this.#publishState() + else if (failed && this.#state.status !== 'failed') { + const component = failedComponent ?? (runtime?.status === 'offline' ? connection : null) + Object.assign(transition, { + transition: this.#state.hasBeenReady ? 'degraded' : 'start_failed', + ...(component ? { component: component.component, operationId: component.operationId } : {}), + failure: this.#failure ?? component?.failure ?? (runtime?.errorCode ? { errorCode: runtime.errorCode } : {}), + }) + } + this.#publish(next, transition) } - #publishState(): void { - this.#state = { ...this.#state, revision: this.#state.revision + 1 } - for (const listener of this.#listeners) { - try { - listener(this.state) - } - catch {} - } + #publish(state: ApplicationStartupState, change: Omit = {}): void { + if (JSON.stringify(state) === JSON.stringify(this.#state)) + return + this.#state = { ...state, revision: this.#state.revision + 1 } + this.#changes.fire(copyEventSnapshot({ state: this.#state, ...change })) } } diff --git a/apps/buddy/electron/main/app/__tests__/DesktopStartup.spec.ts b/apps/buddy/electron/main/app/__tests__/DesktopStartup.spec.ts index 0ec83aaa..cbc65f79 100644 --- a/apps/buddy/electron/main/app/__tests__/DesktopStartup.spec.ts +++ b/apps/buddy/electron/main/app/__tests__/DesktopStartup.spec.ts @@ -3,32 +3,30 @@ import { describe, expect, it } from 'vitest' import { PrivateDirectoryError } from '../../../../platform/windows/privateDirectories' import { ServiceHost } from '../../../../shared/lifecycle/ServiceHost' import { ApplicationEvents } from '../../../../shared/observability/ApplicationEvents' +import { observeLifecycleDiagnostics } from '../../../../shared/observability/lifecycleDiagnostics' import { DesktopStartup } from '../DesktopStartup' +import { observeStartupDiagnostics } from '../startupDiagnostics' +import { createStartupFixture } from './startupFixture' function fixture() { + const state = createStartupFixture() const events: ApplicationDiagnostic[] = [] const publisher = new ApplicationEvents() - const startup = new DesktopStartup(publisher) - publisher.subscribe((event) => { - events.push(event) - }) - const send = (component: string, event: string, operationId = component, generation = 'runtime-1') => startup.observe({ component, event, level: 'info', operationId, generation }, { sourceId: generation }) - const complete = (component: string, generation = 'runtime-1') => { - const operationId = component === 'runtime.connection' ? generation : component - send(component, 'component.starting', operationId, generation) - send(component, 'component.ready', operationId, generation) - } - return { events, startup, send, complete } + publisher.subscribe(event => events.push(event)) + const stopDiagnostics = observeStartupDiagnostics(state.startup, publisher) + return { ...state, events, stopDiagnostics } } describe('application lifecycle snapshot', () => { it('retains the first failure and its operation without relabeling cleanup as cancellation', async () => { const events = new ApplicationEvents() const records: ApplicationDiagnostic[] = [] - const startup = new DesktopStartup(events) + const startup = new DesktopStartup() + const host = new ServiceHost() events.subscribe(event => records.push(event)) - events.subscribe(startup.observe) - const host = new ServiceHost(events) + observeStartupDiagnostics(startup, events) + observeLifecycleDiagnostics(host.lifecycle, events) + startup.bindDesktop(host.lifecycle) const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_UNSAFE', { kind: 'private_directories', operation: 'validate_acl', directoryRole: 'session_data', exitCode: 1 }) await expect(host.start('desktop', () => host.step('desktop.environment', () => { throw error @@ -65,12 +63,12 @@ describe('application lifecycle snapshot', () => { }) it('waits for renderer hydration, recovers, and publishes readiness once', () => { - const { startup, events, complete, send } = fixture() + const { startup, events, complete, send, connect } = fixture() complete('desktop') - complete('runtime.connection') - send('renderer', 'component.starting') - send('renderer.providers', 'component.starting') - send('renderer.providers', 'component.start_failed') + connect('runtime-1') + send('renderer', 'starting') + send('renderer.providers', 'starting') + send('renderer.providers', 'start_failed') expect(startup.state.status).toBe('failed') expect(startup.state.hasBeenReady).toBe(false) complete('renderer') @@ -82,29 +80,54 @@ describe('application lifecycle snapshot', () => { expect(startup.state.stages[0]?.status).toBe('completed') }) - it('rejects delayed events from both the old runtime and its renderer refresh', () => { - const { startup, send, complete } = fixture() + it('rejects delayed renderer reports after the Runtime generation changes while retaining their diagnostics', () => { + const { startup, complete, connect, events } = fixture() complete('desktop') - complete('runtime.connection') + connect('runtime-1') complete('renderer') + connect('runtime-2', 'starting') const revision = startup.state.revision - send('runtime.connection', 'component.starting', 'runtime-2', 'runtime-2') - send('runtime.database', 'component.ready', 'old', 'runtime-1') complete('renderer', 'runtime-1') expect(startup.state.status).toBe('starting') - expect(startup.state.revision).toBe(revision + 1) - complete('runtime.connection', 'runtime-2') + expect(startup.state.revision).toBe(revision) + expect(events).toContainEqual(expect.objectContaining({ event: 'component.ready', component: 'renderer', generation: 'runtime-1' })) + connect('runtime-2') complete('renderer', 'runtime-2') expect(startup.state.status).toBe('ready') }) it('does not announce readiness after shutdown has begun', () => { - const { startup, events, complete } = fixture() + const { startup, events, complete, connect } = fixture() complete('desktop') - complete('runtime.connection') + connect('runtime-1') startup.stopping() complete('renderer') expect(startup.state.status).toBe('stopping') expect(events.some(event => event.event === 'app.ready')).toBe(false) }) + + it('becomes ready without diagnostics and preserves observation after a consumer fails', () => { + const { startup, complete, connect, stopDiagnostics, events } = fixture() + stopDiagnostics() + startup.onStateChange(() => { + throw new Error('optional view failed') + }) + complete('desktop') + connect('runtime-1') + complete('renderer') + expect(startup.state.status).toBe('ready') + connect('runtime-2', 'starting') + connect('runtime-2') + complete('renderer', 'runtime-2') + expect(startup.reconcile().status).toBe('ready') + expect(events).toEqual([]) + }) + + it('reconstructs completed managed stages when subscribed after initialization', async () => { + const host = new ServiceHost() + await host.start('desktop', () => true) + const startup = new DesktopStartup() + startup.bindDesktop(host.lifecycle) + expect(startup.state.stages).toContainEqual(expect.objectContaining({ stage: 'desktop', status: 'completed' })) + }) }) diff --git a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts index ef99d49b..e1a0167b 100644 --- a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts +++ b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts @@ -1,4 +1,5 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import type { DesktopDiagnosticEvent } from '../../desktopDiagnostics' import { readdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { deferred } from '@buddy-tests/deferred' @@ -28,14 +29,14 @@ beforeEach(() => { native.save.mockReset() }) -async function fixture() { +async function fixture(record: (event: DesktopDiagnosticEvent) => boolean = () => true) { const directory = await createTemporaryDirectory('lexora-diagnostic-export-') await writeFile(join(directory, 'application.jsonl'), `${JSON.stringify({ schemaVersion: 1, timestamp: '2026-09-24T00:00:00.000Z', elapsedMs: 1, sequence: 1, launchId: 'launch-current', appVersion: '0.8.7', platform: 'linux', collectorPid: 42, scope: 'local-service', level: 'error', event: 'run.failed', errorCode: 'MODEL_STREAM_INCOMPLETE' })}\n`) const frame = {} const sender = { mainFrame: frame } const window = { webContents: sender, isDestroyed: () => false } as unknown as BrowserWindow const event = { sender, senderFrame: frame } as unknown as IpcMainInvokeEvent - registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window) + registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window, record) const handler = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics)! return { directory, event, handler } } @@ -76,3 +77,57 @@ describe('diagnostic export IPC', () => { await expect(handler(event, { launch: 'current' })).resolves.toMatchObject({ status: 'saved' }) }) }) + +describe('renderer diagnostic IPC', () => { + const diagnostic = { event: 'workbench.copy.saved', level: 'info', workingCopyId: '10000000-0000-4000-8000-000000000001', contentVersion: 3, savedVersion: 2, dirty: true, sourceSequence: 1, occurredAt: '2026-09-28T00:00:00.000Z' } + + it('binds safe producer identities while retaining source sequences and ignores repeated delivery', async () => { + const records: DesktopDiagnosticEvent[] = [] + const { event } = await fixture((record) => { + records.push(record) + + return true + }) + const report = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsReport)! + const first = { sourceId: crypto.randomUUID(), diagnostic } + const second = { sourceId: crypto.randomUUID(), diagnostic } + await expect(report(event, first)).resolves.toBe(true) + await expect(report(event, first)).resolves.toBe(true) + await expect(report(event, second)).resolves.toBe(true) + expect(records).toHaveLength(2) + expect(records.map(record => record.sourceSequence)).toEqual([1, 1]) + expect(records[0]).toMatchObject({ component: 'renderer.workbench', scope: 'desktop', workingCopyId: diagnostic.workingCopyId, contentVersion: 3, savedVersion: 2, dirty: true }) + expect(records[0]!.producerInstanceId).not.toBe(records[1]!.producerInstanceId) + expect(records[0]!.producerInstanceId).not.toBe(first.sourceId) + }) + + it('rejects foreign senders, lifecycle impersonation and arbitrary payloads before recording', async () => { + const records: DesktopDiagnosticEvent[] = [] + const { event } = await fixture((record) => { + records.push(record) + + return true + }) + const report = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsReport)! + const input = { sourceId: crypto.randomUUID(), diagnostic } + await expect(report({ ...event, senderFrame: {} } as IpcMainInvokeEvent, input)).rejects.toThrow('Untrusted') + for (const extra of [{ event: 'component.ready' }, { key: '/fixture/private' }, { workingCopyId: 'file:/fixture/private' }, { operationId: '/fixture/private' }, { body: 'private content' }, { producerInstanceId: crypto.randomUUID() }]) + await expect(report(event, { ...input, diagnostic: { ...diagnostic, ...extra } })).rejects.toThrow() + expect(records).toEqual([]) + }) + + it('returns a refused acknowledgement when the collector is closed or fails', async () => { + let throwing = false + const { event } = await fixture(() => { + if (throwing) + throw new Error('fixture-private-collector') + return false + }) + const report = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsReport)! + const input = { sourceId: crypto.randomUUID(), diagnostic } + await expect(report(event, input)).resolves.toBe(false) + await expect(report(event, input)).resolves.toBe(false) + throwing = true + await expect(report(event, { ...input, diagnostic: { ...diagnostic, sourceSequence: 2 } })).resolves.toBe(false) + }) +}) diff --git a/apps/buddy/electron/main/app/__tests__/startupFixture.ts b/apps/buddy/electron/main/app/__tests__/startupFixture.ts new file mode 100644 index 00000000..66c2d6d2 --- /dev/null +++ b/apps/buddy/electron/main/app/__tests__/startupFixture.ts @@ -0,0 +1,41 @@ +import type { RuntimeLifecycleChange, RuntimeLifecycleSnapshot } from '../../../../shared/lifecycle/runtimeLifecycle' +import type { LifecycleComponent } from '../../../../shared/lifecycle/serviceLifecycle' +import { Emitter } from '../../../../shared/events/Emitter' +import { ServiceLifecycleSource } from '../../../../shared/lifecycle/ServiceLifecycleSource' +import { DesktopStartup } from '../DesktopStartup' + +export function createStartupFixture() { + const startup = new DesktopStartup() + const desktop = new ServiceLifecycleSource() + const runtimeChanges = new Emitter(() => {}) + let runtime: RuntimeLifecycleSnapshot = { revision: 0, generation: null, status: 'stopped', errorCode: null, connection: null, services: null } + let renderer = new ServiceLifecycleSource() + startup.bindDesktop(desktop.reader) + startup.bindRuntime({ get lifecycleState() { + return runtime + }, onDidChangeLifecycle: runtimeChanges.event }) + const connect = (generation: string, status: 'starting' | 'ready' | 'offline' = 'ready') => { + runtime = { + revision: runtime.revision + 1, + generation, + status, + errorCode: status === 'offline' ? 'RUNTIME_START_FAILED' : null, + connection: { component: 'runtime.connection', kind: 'service', operationId: generation, status: status === 'ready' ? 'ready' : status === 'offline' ? 'start_failed' : 'starting' }, + services: null, + } + runtimeChanges.fire({ snapshot: runtime }) + } + const send = (component: string, status: LifecycleComponent['status'], generation = 'runtime-1') => { + if (component === 'renderer' && status === 'starting') + renderer = new ServiceLifecycleSource() + const source = component.startsWith('renderer') ? renderer : desktop + const subscription = component.startsWith('renderer') ? source.reader.onDidChange(change => startup.acceptRenderer({ generation, change })) : null + source.update({ component, kind: 'service', operationId: component, status }) + subscription?.dispose() + } + const complete = (component: string, generation = 'runtime-1') => { + send(component, 'starting', generation) + send(component, 'ready', generation) + } + return { startup, desktop, send, complete, connect } +} diff --git a/apps/buddy/electron/main/app/closeDesktopDiagnostics.ts b/apps/buddy/electron/main/app/closeDesktopDiagnostics.ts new file mode 100644 index 00000000..5ccc416e --- /dev/null +++ b/apps/buddy/electron/main/app/closeDesktopDiagnostics.ts @@ -0,0 +1,15 @@ +import type { DesktopDiagnosticLogger } from '../desktopDiagnostics' +import process from 'node:process' + +const reported = new WeakSet() + +export async function closeDesktopDiagnostics(logger: DesktopDiagnosticLogger): Promise { + const status = await logger.close() + if (reported.has(logger) || (!status.dropped && !status.failed && !status.unconfirmed && !status.closeTimedOut && !status.lastError)) + return + reported.add(logger) + try { + process.stderr.write(`${JSON.stringify({ event: 'recorder.close_incomplete', dropped: status.dropped, failed: status.failed, unconfirmed: status.unconfirmed, closeTimedOut: status.closeTimedOut, ioFailed: status.lastError !== null })}\n`) + } + catch {} +} diff --git a/apps/buddy/electron/main/app/environment.ts b/apps/buddy/electron/main/app/environment.ts index 310083b5..6e656379 100644 --- a/apps/buddy/electron/main/app/environment.ts +++ b/apps/buddy/electron/main/app/environment.ts @@ -21,6 +21,7 @@ import { bootstrapStep } from './desktopBootstrap' import { desktopIcons } from './desktopIcons' import { DesktopStartup } from './DesktopStartup' import { checkDesktopDirectories, criticalDesktopDirectories, prepareDesktopPrivateStorage } from './desktopStorage' +import { observeStartupDiagnostics } from './startupDiagnostics' export function prepareDesktopEnvironment(): DesktopEnvironment { const desktopHost = desktopHosts[currentPlatform.id] @@ -52,9 +53,9 @@ export function prepareDesktopEnvironment(): DesktopEnvironment { userHome: homedir(), }) const events = new ApplicationEvents() - const startup = new DesktopStartup(events) + const startup = new DesktopStartup(() => events.publish({ event: 'observer.failed', component: 'startup', level: 'warn' })) events.subscribe(event => diagnostics.record({ ...event, scope: 'desktop' })) - events.subscribe(startup.observe) + observeStartupDiagnostics(startup, events) return { diagnostics, events, diff --git a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts index 82726c1e..e74cae16 100644 --- a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts +++ b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts @@ -1,13 +1,41 @@ -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebFrameMain } from 'electron' +import type { DesktopDiagnosticEvent } from '../desktopDiagnostics' import type { ApplicationLogReader } from '../diagnostics/ApplicationLogReader' import { ipcMain } from 'electron' import { applicationLogExportSchema, applicationLogQuerySchema } from '../../../shared/diagnostics/applicationLog' +import { rendererDiagnosticReportSchema } from '../../../shared/diagnostics/rendererDiagnostic' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { saveApplicationDiagnosticBundle } from '../diagnostics/saveApplicationDiagnosticBundle' import { assertTrustedSender } from '../ipc' -export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null): () => void { +export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null, record: (event: DesktopDiagnosticEvent) => boolean): () => void { let exporting = false + const producers = new WeakMap>() + ipcMain.handle(DESKTOP_IPC_CHANNELS.appLogsReport, async (event, input: unknown) => { + assertTrustedSender(event, getWindow()) + const report = rendererDiagnosticReportSchema.parse(input) + const frame = event.senderFrame! + const sources = producers.get(frame) ?? new Map() + producers.set(frame, sources) + let source = sources.get(report.sourceId) + if (!source) { + if (sources.size >= 32) + return false + source = { id: crypto.randomUUID(), sequence: 0, accepted: false } + sources.set(report.sourceId, source) + } + if (report.diagnostic.sourceSequence <= source.sequence) + return report.diagnostic.sourceSequence === source.sequence && source.accepted + source.sequence = report.diagnostic.sourceSequence + source.accepted = false + try { + source.accepted = record({ ...report.diagnostic, scope: 'desktop', component: 'renderer.workbench', sourceId: `renderer:${source.id}`, producerInstanceId: source.id }) + return source.accepted + } + catch { + return false + } + }) ipcMain.handle(DESKTOP_IPC_CHANNELS.appLogsQuery, async (event, input: unknown) => { assertTrustedSender(event, getWindow()) const query = applicationLogQuerySchema.parse(input) @@ -36,6 +64,7 @@ export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindo } }) return () => { + ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appLogsReport) ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appLogsQuery) ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics) } diff --git a/apps/buddy/electron/main/app/registerStartupIpc.ts b/apps/buddy/electron/main/app/registerStartupIpc.ts index 58149216..a5f27aa7 100644 --- a/apps/buddy/electron/main/app/registerStartupIpc.ts +++ b/apps/buddy/electron/main/app/registerStartupIpc.ts @@ -1,22 +1,21 @@ import type { BrowserWindow } from 'electron' -import type { ApplicationEvents } from '../../../shared/observability/ApplicationEvents' import type { DesktopStartup } from './DesktopStartup' import { ipcMain } from 'electron' -import { applicationDiagnosticSchema } from '../../../shared/diagnostics/applicationDiagnostic' +import { rendererLifecycleReportSchema } from '../../../shared/lifecycle/serviceLifecycle' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { assertTrustedSender } from '../ipc' -export function registerStartupIpc(startup: DesktopStartup, getWindow: () => BrowserWindow | null, events: ApplicationEvents): () => void { +export function registerStartupIpc(startup: DesktopStartup, getWindow: () => BrowserWindow | null): () => void { ipcMain.handle(DESKTOP_IPC_CHANNELS.appStartupGetState, (event) => { assertTrustedSender(event, getWindow()) - return startup.state + return startup.reconcile() }) ipcMain.handle(DESKTOP_IPC_CHANNELS.appStartupReport, (event, input: unknown) => { assertTrustedSender(event, getWindow()) - const report = applicationDiagnosticSchema.parse(input) - if (!report.component || !(report.component === 'renderer' || report.component.startsWith('renderer.')) || !(report.event.startsWith('component.') || report.event.startsWith('startup.step.'))) + const report = rendererLifecycleReportSchema.parse(input) + if (report.change.snapshot.components.some(component => component.component !== 'renderer' && !component.component.startsWith('renderer.'))) throw new Error('Invalid renderer startup stage') - events.publish(report) + startup.acceptRenderer(report) }) const stop = startup.onStateChange((state) => { const contents = getWindow()?.webContents diff --git a/apps/buddy/electron/main/app/startupDiagnostics.ts b/apps/buddy/electron/main/app/startupDiagnostics.ts new file mode 100644 index 00000000..84188c52 --- /dev/null +++ b/apps/buddy/electron/main/app/startupDiagnostics.ts @@ -0,0 +1,30 @@ +import type { ApplicationEvents } from '../../../shared/observability/ApplicationEvents' +import type { DesktopStartup } from './DesktopStartup' +import { lifecycleDiagnostic } from '../../../shared/observability/lifecycleDiagnostics' + +export function observeStartupDiagnostics(startup: DesktopStartup, events: ApplicationEvents): () => void { + let previousStatus = startup.state.status + const renderer = startup.onDidReportRenderer((report) => { + if (report.change.component) + events.publish({ ...lifecycleDiagnostic(report.change.component), generation: report.generation }) + }) + const subscription = startup.onDidChange((change) => { + if (change.state.status === 'stopping' && previousStatus !== 'stopping') + events.publish({ event: 'app.stopping', level: 'info' }) + previousStatus = change.state.status + if (!change.transition) + return + events.publish({ + event: `app.${change.transition}`, + level: change.transition === 'stop_failed' || change.state.status === 'failed' ? 'error' : 'info', + component: change.component, + parentOperationId: change.operationId, + durationMs: change.durationMs, + ...change.failure, + }) + }) + return () => { + subscription.dispose() + renderer.dispose() + } +} diff --git a/apps/buddy/electron/main/browser/BrowserHost.ts b/apps/buddy/electron/main/browser/BrowserHost.ts index c8813481..1b0ba463 100644 --- a/apps/buddy/electron/main/browser/BrowserHost.ts +++ b/apps/buddy/electron/main/browser/BrowserHost.ts @@ -17,11 +17,14 @@ import type { DesktopBrowserSetSurfaceInput, DesktopBrowserState, } from '../../../shared/browser/browserDesktopApi' +import type { BrowserHostChange, BrowserHostFact } from './BrowserHostEvents' import type { BrowserPage } from './BrowserPageSession' import type { BrowserSessionTeardownReason } from './BrowserSessionRegistry' import type { SemanticBrowserDriver, SemanticBrowserScreenshot, SemanticBrowserScreenshotReference } from './SemanticBrowserDriver' import { randomUUID } from 'node:crypto' import { BROWSER_WAIT_DEFAULT_QUIET_MS } from '../../../shared/browser' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { BrowserHostError } from './BrowserHostError' import { BrowserOperationGuard } from './BrowserOperationGuard' import { BrowserPageSession, snapshot } from './BrowserPageSession' @@ -51,12 +54,8 @@ interface BrowserHostOptions { operations?: BrowserOperationGuard createId?: () => string createPage?: (descriptor: DesktopBrowserGuestDescriptor) => BrowserPage - onGuestSetChanged?: () => void - onSessionClosed?: ( - state: DesktopBrowserState, - reason: BrowserSessionTeardownReason, - ) => void - onStateChanged?: (state: DesktopBrowserState) => void + requestGuestAttachment?: () => void + revokeSession?: (sessionId: string) => void window: BrowserWindow } @@ -85,6 +84,9 @@ export interface BrowserPageScreenshot { } export class BrowserHost { + readonly #changes = new Emitter(() => console.error('BROWSER_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 readonly #getFreezeDelay: (visible: boolean) => number | null readonly #onActivityError: () => void readonly #getDefaultZoomFactor: () => number @@ -92,13 +94,8 @@ export class BrowserHost { readonly #createId: () => string readonly #createPage: ((descriptor: DesktopBrowserGuestDescriptor) => BrowserPage) | null readonly #evictedConversationIds = new Set() - readonly #onSessionClosed: ( - state: DesktopBrowserState, - reason: BrowserSessionTeardownReason, - ) => void - - readonly #onStateChanged: (state: DesktopBrowserState) => void - readonly #onGuestSetChanged: () => void + readonly #requestGuestAttachment: () => void + readonly #revokeSession: (sessionId: string) => void readonly #sessions: BrowserSessionRegistry readonly #window: BrowserWindow readonly #windowClosedListener: () => void @@ -109,6 +106,7 @@ export class BrowserHost { ) => void #disposed = false + #drained: Promise = Promise.resolve() constructor(options: BrowserHostOptions) { this.#getFreezeDelay = options.getFreezeDelay ?? (() => null) this.#onActivityError = options.onActivityError ?? (() => {}) @@ -116,9 +114,8 @@ export class BrowserHost { this.#getDefaultZoomFactor = options.getDefaultZoomFactor ?? (() => 1) this.#createId = options.createId ?? randomUUID this.#createPage = options.createPage ?? null - this.#onGuestSetChanged = options.onGuestSetChanged ?? (() => {}) - this.#onSessionClosed = options.onSessionClosed ?? (() => {}) - this.#onStateChanged = options.onStateChanged ?? (() => {}) + this.#requestGuestAttachment = options.requestGuestAttachment ?? (() => {}) + this.#revokeSession = options.revokeSession ?? (() => {}) this.#sessions = new BrowserSessionRegistry({ createId: this.#createId, maxSessions: 4, @@ -136,6 +133,10 @@ export class BrowserHost { return this.#disposed } + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, disposed: this.#disposed, sessions: this.#sessions.values().map(session => snapshot(session.state)), guests: this.#sessions.values().map(session => ({ ...session.descriptor, attached: !!session.page })) }) + } + hasAgentControl(): boolean { return this.#sessions.values().some(session => session.state.controller === 'agent') } @@ -193,10 +194,13 @@ export class BrowserHost { } session.state.status = 'error' } - if (wasCreated) - this.#onGuestSetChanged() - if (wasCreated) + if (wasCreated) { + this.#emit({ kind: 'session', status: 'opened', state: snapshot(session.state) }) + const page = this.#createPage?.(session.descriptor) + if (page) + session.attach(page) this.#publish(session) + } return snapshot(session.state) } catch (error) { @@ -259,6 +263,7 @@ export class BrowserHost { session.state.controller = 'agent' this.#sessions.setProtected(input.sessionId, 'runtime', true) this.#publish(session) + this.#emit({ kind: 'control', sessionId: input.sessionId, pageId: session.state.pageId, controller: 'agent', controlEpoch: session.state.controlEpoch }) return { controller: 'agent', controlEpoch: session.state.controlEpoch, @@ -345,6 +350,8 @@ export class BrowserHost { releaseQueue = resolve }) await predecessor + const identity = { operationId: randomUUID(), sessionId: input.sessionId, pageId: input.pageId, action: input.action.kind } + let effect: 'not-dispatched' | 'unknown' | 'confirmed' = 'not-dispatched' try { this.#operations.assertCanMutate() const session = this.#requireSession(input.sessionId) @@ -367,6 +374,8 @@ export class BrowserHost { observationId: input.observationId, } let mayStartNavigation = false + effect = 'unknown' + this.#emit({ kind: 'action', ...identity, phase: 'dispatched', effect }) switch (input.action.kind) { case 'navigate': semanticDriver.assertObservation(reference) @@ -427,17 +436,25 @@ export class BrowserHost { this.#publish(session) } + effect = 'confirmed' + this.#emit({ kind: 'action', ...identity, phase: 'confirmed', effect }) await this.#waitForPostActionSettlement(session, mayStartNavigation) const observation = await this.observe({ pageId: session.state.pageId, sessionId: session.state.sessionId, }) + this.#emit({ kind: 'action', ...identity, phase: 'verified', effect }) return { actionKind: input.action.kind, observation, state: snapshot(session.state), } } + catch (error) { + const errorCode = error instanceof BrowserHostError || error instanceof SemanticBrowserDriverError ? error.code : 'BROWSER_PAGE_FAILED' + this.#emit({ kind: 'action', ...identity, phase: 'failed', effect, errorCode }) + throw error + } finally { releaseQueue() } @@ -923,8 +940,14 @@ export class BrowserHost { this.#disposed = true this.#window.off('closed', this.#windowClosedListener) this.#window.webContents.off('will-attach-webview', this.#willAttachWebviewListener) + const pending = this.#sessions.values().map(session => session.actionTail) this.#sessions.dispose() this.#evictedConversationIds.clear() + this.#drained = Promise.allSettled(pending).then(() => this.#changes.dispose()) + } + + whenIdle(): Promise { + return this.#drained } #assertActive(): void { @@ -950,8 +973,8 @@ export class BrowserHost { createId: this.#createId, getDefaultZoomFactor: this.#getDefaultZoomFactor, operations: this.#operations, - onStateChanged: this.#onStateChanged, - onGuestSetChanged: this.#onGuestSetChanged, + onStateChanged: state => this.#emit({ kind: 'state', state }), + onGuestChanged: status => this.#emit({ kind: 'guest', status, sessionId, pageId: session.state.pageId }), onHumanInput: () => this.#acceptHumanPageInput(session), isCurrent: () => this.#sessions.get(sessionId) === session, state: { @@ -971,9 +994,6 @@ export class BrowserHost { visible: false, }, }) - const page = this.#createPage?.(descriptor) - if (page) - session.attach(page) return session } @@ -992,13 +1012,13 @@ export class BrowserHost { this.#publish(session) } session.state.visible = false - this.#onSessionClosed(snapshot(session.state), reason) + this.#revokeSession(session.state.sessionId) const page = session.page session.releasePage() session.pageReady.reject(this.#sessionNotFound(session.state.sessionId)) if (page && !page.isDestroyed()) page.close() - this.#onGuestSetChanged() + this.#emit({ kind: 'session', state: snapshot(session.state), status: 'closed', reason }) } #hide(session: BrowserPageSession): void { @@ -1040,6 +1060,10 @@ export class BrowserHost { session.publish() } + #emit(fact: BrowserHostFact): void { + this.#changes.fire(copyEventSnapshot({ ...fact, revision: ++this.#revision })) + } + #assertCurrentPage( session: BrowserPageSession, pageId: string, @@ -1097,6 +1121,7 @@ export class BrowserHost { session.semanticDriver?.invalidateDocument() this.#sessions.setProtected(session.state.sessionId, 'runtime', false) this.#publish(session) + this.#emit({ kind: 'control', sessionId: session.state.sessionId, pageId: session.state.pageId, controller: 'human', controlEpoch: session.state.controlEpoch }) return snapshot(session.state) } @@ -1147,7 +1172,7 @@ export class BrowserHost { await session.resumePage() return page } - this.#onGuestSetChanged() + this.#requestGuestAttachment() const attached = await waitForBrowserPage(session.pageReady.promise) await session.resumePage() return attached diff --git a/apps/buddy/electron/main/browser/BrowserHostEvents.ts b/apps/buddy/electron/main/browser/BrowserHostEvents.ts new file mode 100644 index 00000000..0517fc5c --- /dev/null +++ b/apps/buddy/electron/main/browser/BrowserHostEvents.ts @@ -0,0 +1,14 @@ +import type { BrowserAction } from '../../../shared/browser' +import type { DesktopBrowserState } from '../../../shared/browser/browserDesktopApi' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { BrowserSessionTeardownReason } from './BrowserSessionRegistry' + +export type BrowserHostFact + = | { readonly kind: 'session', readonly state: EventSnapshot, readonly status: 'opened' } + | { readonly kind: 'session', readonly state: EventSnapshot, readonly status: 'closed', readonly reason: BrowserSessionTeardownReason } + | { readonly kind: 'state', readonly state: EventSnapshot } + | { readonly kind: 'guest', readonly sessionId: string, readonly pageId: string, readonly status: 'attached' | 'detached' | 'crashed' } + | { readonly kind: 'control', readonly sessionId: string, readonly pageId: string, readonly controller: 'agent' | 'human', readonly controlEpoch: number } + | { readonly kind: 'action', readonly operationId: string, readonly sessionId: string, readonly pageId: string, readonly action: BrowserAction['kind'], readonly phase: 'dispatched' | 'confirmed' | 'verified' | 'failed', readonly effect: 'not-dispatched' | 'unknown' | 'confirmed', readonly errorCode?: string } + +export type BrowserHostChange = BrowserHostFact & { readonly revision: number } diff --git a/apps/buddy/electron/main/browser/BrowserIntegration.ts b/apps/buddy/electron/main/browser/BrowserIntegration.ts index 3575c336..2868ded5 100644 --- a/apps/buddy/electron/main/browser/BrowserIntegration.ts +++ b/apps/buddy/electron/main/browser/BrowserIntegration.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import type { LocalEndpoint } from '../../../platform/ipc/localTransport' import type { BrowserPreferences } from '../../../shared/browser/browserPreferences' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { BrowserAdapterServer } from './BrowserAdapterServer' import { BrowserAdapterTestLeasePublisher } from './BrowserAdapterTestLeasePublisher' @@ -15,6 +16,7 @@ interface BrowserIntegrationOptions { endpoint: LocalEndpoint getPreferences: () => BrowserPreferences testBrokerSocketPath?: string + report?: ApplicationDiagnosticReporter } export class BrowserIntegration { @@ -25,6 +27,7 @@ export class BrowserIntegration { #host: BrowserHost | null = null #adapterStarted = false #testLeasePublisher: BrowserAdapterTestLeasePublisher | null = null + readonly #closingHosts = new Set>() readonly #options: BrowserIntegrationOptions @@ -54,21 +57,45 @@ export class BrowserIntegration { window, operations: this.#operations, getDefaultZoomFactor: () => this.#options.getPreferences().defaultZoomFactor, - onGuestSetChanged: () => { + requestGuestAttachment: () => { if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC_CHANNELS.browserGuestsChanged) }, - onSessionClosed: state => this.adapter.revokeSession(state.sessionId), - onStateChanged: (state) => { - this.#testLeasePublisher?.publish(state) - if (!window.isDestroyed()) - window.webContents.send(DESKTOP_IPC_CHANNELS.browserStateChanged, state) - }, + revokeSession: id => this.adapter.revokeSession(id), + }) + this.#host.onDidChange((change) => { + if ((change.kind === 'session' || (change.kind === 'guest' && change.status === 'detached')) && !window.isDestroyed()) + window.webContents.send(DESKTOP_IPC_CHANNELS.browserGuestsChanged) + }) + this.#host.onDidChange((change) => { + if (change.kind === 'state' && !window.isDestroyed()) + window.webContents.send(DESKTOP_IPC_CHANNELS.browserStateChanged, change.state) + }) + this.#host.onDidChange((change) => { + if (change.kind === 'state') + this.#testLeasePublisher?.publish(structuredClone(change.state)) + }) + this.#host.onDidChange((change) => { + if (change.kind === 'state') + return + const sessionId = change.kind === 'session' ? change.state.sessionId : change.sessionId + const common = { sessionId, revision: change.revision, component: 'desktop.browser' } + if (change.kind === 'action') { + this.#options.report?.({ ...common, event: `browser.action.${change.phase}${change.phase === 'failed' ? `.${change.effect.replaceAll('-', '_')}` : ''}`, level: change.phase === 'failed' ? 'warn' : 'info', operationId: change.operationId, method: change.action, ...(change.errorCode ? { errorCode: change.errorCode } : {}) }) + } + else { + this.#options.report?.({ ...common, event: `browser.${change.kind}.${change.kind === 'control' ? change.controller : change.status}`, level: 'info' }) + } }) } closeWindow(): void { - this.#host?.dispose() + const host = this.#host + host?.dispose() + if (host) { + const pending = host.whenIdle().finally(() => this.#closingHosts.delete(pending)) + this.#closingHosts.add(pending) + } this.#host = null } @@ -84,6 +111,7 @@ export class BrowserIntegration { } async stopAdapter(): Promise { + await Promise.all([...this.#closingHosts]) this.#testLeasePublisher?.dispose() this.#testLeasePublisher = null if (this.#adapterStarted) { diff --git a/apps/buddy/electron/main/browser/BrowserPageSession.ts b/apps/buddy/electron/main/browser/BrowserPageSession.ts index b1105454..54eacdd6 100644 --- a/apps/buddy/electron/main/browser/BrowserPageSession.ts +++ b/apps/buddy/electron/main/browser/BrowserPageSession.ts @@ -73,7 +73,7 @@ interface BrowserPageSessionOptions { createId: () => string descriptor: DesktopBrowserGuestDescriptor getDefaultZoomFactor: () => number - onGuestSetChanged: () => void + onGuestChanged: (status: 'attached' | 'detached' | 'crashed') => void onHumanInput: () => void onStateChanged: (state: DesktopBrowserState) => void isCurrent: () => boolean @@ -199,6 +199,7 @@ export class BrowserPageSession { this.pageReady.resolve(page) this.refreshPageState() this.publish() + this.#options.onGuestChanged('attached') } #onHumanInput(): void { @@ -375,6 +376,7 @@ export class BrowserPageSession { } this.state.status = 'error' this.publish() + this.#options.onGuestChanged('crashed') }) this.#listen(page, 'unresponsive', () => { this.state.error = { @@ -409,7 +411,7 @@ export class BrowserPageSession { this.state.title = '' this.state.url = 'about:blank' this.publish() - this.#options.onGuestSetChanged() + this.#options.onGuestChanged('detached') }) } diff --git a/apps/buddy/electron/main/browser/__tests__/BrowserHost.control.spec.ts b/apps/buddy/electron/main/browser/__tests__/BrowserHost.control.spec.ts index 4bac30bf..367ca89f 100644 --- a/apps/buddy/electron/main/browser/__tests__/BrowserHost.control.spec.ts +++ b/apps/buddy/electron/main/browser/__tests__/BrowserHost.control.spec.ts @@ -4,6 +4,21 @@ import { BrowserOperationGuard } from '../BrowserOperationGuard' import { configureSemanticObservation, configureSensitiveSemanticObservation, createFixture } from './browserHostFixture' describe('browserHost control and semantic actions', () => { + it('preserves confirmed dispatch when the following observation fails', async () => { + const fixture = createFixture() + const session = fixture.host.ensureSession('conversation') + configureSemanticObservation(fixture.webContents) + await fixture.host.navigate(session.sessionId, 'https://example.com/') + const observation = await fixture.host.observe({ sessionId: session.sessionId, pageId: session.pageId }) + const lease = fixture.host.acquireControl({ sessionId: session.sessionId, pageId: session.pageId }) + const changes: Array<{ kind: string, phase?: string, effect?: string }> = [] + fixture.host.onDidChange(change => changes.push(change)) + vi.spyOn(fixture.host, 'observe').mockRejectedValueOnce(new Error('fixture observation failed')) + await expect(fixture.host.act({ ...lease, action: { kind: 'click', ref: 'e1' }, frameId: 'main-frame', documentRevision: observation.documentRevision, observationId: observation.observationId })).rejects.toThrow('fixture observation failed') + expect(changes.filter(change => change.kind === 'action')).toMatchObject([{ phase: 'dispatched', effect: 'unknown' }, { phase: 'confirmed', effect: 'confirmed' }, { phase: 'failed', effect: 'confirmed' }]) + fixture.host.dispose() + await fixture.host.whenIdle() + }) it('invalidates an approved target when page zoom changes', async () => { const fixture = createFixture() const state = fixture.host.ensureSession('conversation') diff --git a/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts b/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts index 43143d88..84c19204 100644 --- a/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts +++ b/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts @@ -26,10 +26,14 @@ export function createFixture(options: { operations?: BrowserOperationGuard, get ...options, createId: () => ids.shift()!, createPage: createPage as never, - onSessionClosed, - onStateChanged, window: window as never, }) + host.onDidChange((change) => { + if (change.kind === 'state') + onStateChanged(change.state) + if (change.kind === 'session' && change.status === 'closed') + onSessionClosed(change.state, change.reason) + }) return { createPage, diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index ec2d44fa..0babe06d 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -1,4 +1,5 @@ import type { ZodError } from 'zod' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { LexoraConfig, LexoraConfigPatch } from '../../shared/desktopApi' import { randomUUID } from 'node:crypto' import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises' @@ -7,6 +8,8 @@ import process from 'node:process' import { parse, stringify } from 'smol-toml' import { z } from 'zod' import { browserPreferencesSchema, DEFAULT_BROWSER_PREFERENCES } from '../../../shared/browser/browserPreferences' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { extensionAuthorSchema } from '../../../shared/extensions/extensionIdentity' import { DEFAULT_PROXY_SETTINGS, proxySettingsSchema } from '../../../shared/network/proxySettings' import { DEFAULT_RUNTIME_PREFERENCES, runtimePreferencesSchema } from '../../../shared/runtime/runtimePreferences' @@ -123,7 +126,19 @@ export class LexoraConfigError extends Error { } } +export type LexoraConfigChange = { + readonly revision: number + readonly operationId: string + readonly groups: readonly (keyof LexoraConfig)[] +} & ({ readonly kind: 'committed', readonly config: EventSnapshot } | { readonly kind: 'applied' | 'apply-failed' | 'commit-failed' | 'rolled-back' | 'rollback-failed' | 'cleanup-failed' }) + export class LexoraConfigStore { + readonly #changes = new Emitter(() => console.error('CONFIG_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #persisted: LexoraConfig | null = null + #applied: LexoraConfig | null = null + #disposing: Promise | undefined readonly #configPath: string #writeQueue: Promise = Promise.resolve() @@ -132,20 +147,60 @@ export class LexoraConfigStore { } async read(): Promise { - return decodeConfig(await this.#readFile()) + await this.#writeQueue + const config = decodeConfig(await this.#readFile()) + this.#persisted = structuredClone(config) + return config } + get snapshot() { return copyEventSnapshot({ revision: this.#revision, persisted: this.#persisted, applied: this.#applied }) } + update(patch: LexoraConfigPatch, apply?: (config: LexoraConfig) => Promise | void): Promise { + if (this.#disposing) + return Promise.reject(new Error('CONFIG_STORE_STOPPED')) + const input = structuredClone(patch) const operation = this.#writeQueue.then(async () => { const file = await this.#readFile() const current = decodeConfig(file) - const next = mergeConfig(current, patch) + const next = mergeConfig(current, input) + const groups = (Object.keys(next) as (keyof LexoraConfig)[]).filter(group => JSON.stringify(current[group]) !== JSON.stringify(next[group])) + const operationId = randomUUID() + const publish = (kind: Exclude) => this.#changes.fire(copyEventSnapshot({ kind, revision: ++this.#revision, operationId, groups })) + this.#persisted = structuredClone(current) + let committed = false + let applying = true try { - await apply?.(next) - await this.#write(mergeConfigFile(file, next)) + if (apply) { + await apply(structuredClone(next)) + this.#applied = structuredClone(next) + publish('applied') + } + applying = false + await this.#write(mergeConfigFile(file, next), () => { + committed = true + this.#persisted = structuredClone(next) + this.#changes.fire(copyEventSnapshot({ kind: 'committed', revision: ++this.#revision, operationId, groups, config: next })) + }) } catch (error) { - await apply?.(current) + if (committed) { + publish('cleanup-failed') + throw error + } + this.#applied = applying ? null : this.#applied + publish(applying ? 'apply-failed' : 'commit-failed') + if (apply) { + try { + await apply(structuredClone(current)) + this.#applied = structuredClone(current) + publish('rolled-back') + } + catch (rollbackError) { + this.#applied = null + publish('rollback-failed') + throw new AggregateError([error, rollbackError], 'CONFIG_ROLLBACK_FAILED') + } + } throw error } return next @@ -155,6 +210,11 @@ export class LexoraConfigStore { return operation } + dispose(): Promise { + this.#disposing ??= this.#writeQueue.then(() => this.#changes.dispose()) + return this.#disposing + } + async #readFile(): Promise { let content: string @@ -176,7 +236,7 @@ export class LexoraConfigStore { } } - async #write(config: Record): Promise { + async #write(config: Record, committed: () => void): Promise { const parent = dirname(this.#configPath) const temporaryPath = `${this.#configPath}.${process.pid}.${randomUUID()}.tmp` const content = stringify(config) @@ -193,8 +253,9 @@ export class LexoraConfigStore { await handle.close() } + await chmod(temporaryPath, 0o600) await rename(temporaryPath, this.#configPath) - await chmod(this.#configPath, 0o600) + committed() } finally { await rm(temporaryPath, { force: true }) diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index f1a53957..a5ee839b 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -123,6 +123,8 @@ describe('lexoraConfigStore', () => { it('restores the applied configuration when applying a setting fails without changing the saved profile', async () => { const { store } = await createConfigStore() const previous = await store.read() + const changes: string[] = [] + store.onDidChange(change => changes.push(change.kind)) let active = previous.proxy await expect(store.update({ proxy: { mode: 'custom', server: 'http://127.0.0.1:7890' } }, async (next) => { active = next.proxy @@ -131,6 +133,8 @@ describe('lexoraConfigStore', () => { })).rejects.toThrow('Proxy configuration unavailable') expect(active).toEqual(previous.proxy) expect(await store.read()).toEqual(previous) + expect(changes).toEqual(['apply-failed', 'rolled-back']) + expect(store.snapshot.applied).toEqual(previous) }) it('updates only requested settings and writes a private TOML file atomically', async () => { const { configPath, store } = await createConfigStore() diff --git a/apps/buddy/electron/main/context-panel/ContextPanelHost.ts b/apps/buddy/electron/main/context-panel/ContextPanelHost.ts index 60cb209a..a5495d64 100644 --- a/apps/buddy/electron/main/context-panel/ContextPanelHost.ts +++ b/apps/buddy/electron/main/context-panel/ContextPanelHost.ts @@ -1,8 +1,21 @@ import type { ContextPanelCommand, ContextPanelOperation, ContextPanelOperationRecord, ContextPanelState } from '../../../shared/context-panel/contextPanel' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' + +export type ContextPanelChange = { + readonly operationId: string + readonly revision: number + readonly state: Readonly +} & ({ readonly kind: 'state', readonly actor: ContextPanelOperation['actor'] } | { readonly kind: 'record', readonly status: 'recorded' | 'failed' }) export class ContextPanelHost { #state: ContextPanelState = { revision: 0, open: false, target: null } - readonly #listeners = new Set<(state: ContextPanelState) => void>() + readonly #changes = new Emitter(() => console.error('CONTEXT_PANEL_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Set>() + #revision = 0 + #disposing: Promise | undefined readonly #recordOperation: (operation: ContextPanelOperationRecord) => Promise constructor(recordOperation: (operation: ContextPanelOperationRecord) => Promise) { @@ -13,11 +26,23 @@ export class ContextPanelHost { return structuredClone(this.#state) } - async execute(command: ContextPanelCommand, actor: ContextPanelOperation['actor'] = 'user'): Promise { + get snapshot() { return copyEventSnapshot({ revision: this.#revision, state: this.#state }) } + + execute(command: ContextPanelCommand, actor: ContextPanelOperation['actor'] = 'user'): Promise { + if (this.#disposing) + return Promise.reject(new Error('CONTEXT_PANEL_CLOSED')) + const input = structuredClone(command) + const accepted = Promise.withResolvers() + this.#pending.add(accepted.promise) + void this.#execute(input, actor).then(accepted.resolve, accepted.reject).finally(() => this.#pending.delete(accepted.promise)) + return accepted.promise + } + + async #execute(command: ContextPanelCommand, actor: ContextPanelOperation['actor']): Promise { const open = command.action === 'open' const visibilityChanged = open !== this.#state.open const target = command.action === 'open' ? command.target ?? null : null - if (!visibilityChanged && target === null && this.#state.target === null) { + if (!visibilityChanged && JSON.stringify(target) === JSON.stringify(this.#state.target)) { return this.getState() } this.#state = { @@ -26,16 +51,24 @@ export class ContextPanelHost { target, } const state = this.getState() - for (const listener of this.#listeners) - listener(this.getState()) + const operationId = randomUUID() + this.#changes.fire(copyEventSnapshot({ kind: 'state', operationId, state, actor, revision: ++this.#revision })) const source = target?.source ?? command.source - if (visibilityChanged && source) - await this.#recordOperation({ action: command.action, actor, source, createdAt: new Date().toISOString() }) + if (visibilityChanged && source) { + let status: 'recorded' | 'failed' = 'recorded' + try { + await this.#recordOperation({ action: command.action, actor, source, createdAt: new Date().toISOString() }) + } + catch { + status = 'failed' + } + this.#changes.fire(copyEventSnapshot({ kind: 'record', operationId, state, status, revision: ++this.#revision })) + } return state } - subscribe(listener: (state: ContextPanelState) => void): () => void { - this.#listeners.add(listener) - return () => this.#listeners.delete(listener) + dispose(): Promise { + this.#disposing ??= Promise.allSettled([...this.#pending]).then(() => this.#changes.dispose()) + return this.#disposing } } diff --git a/apps/buddy/electron/main/context-panel/__tests__/ContextPanelHost.spec.ts b/apps/buddy/electron/main/context-panel/__tests__/ContextPanelHost.spec.ts new file mode 100644 index 00000000..9064819f --- /dev/null +++ b/apps/buddy/electron/main/context-panel/__tests__/ContextPanelHost.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { ContextPanelHost } from '../ContextPanelHost' + +describe('context panel owner facts', () => { + it('keeps the accepted visible state when recording fails and isolates observers', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const records: unknown[] = [] + const host = new ContextPanelHost(async (record) => { + records.push(record) + throw new Error('fixture private transport error') + }) + const changes: unknown[] = [] + host.onDidChange(() => { + throw new Error('observer') + }) + host.onDidChange(change => changes.push(change)) + const target = { kind: 'browser' as const, source: { conversationId: 'conversation', runId: 'run' } } + const command = host.execute({ action: 'open', target }, 'harness') + expect(host.getState()).toMatchObject({ open: true, revision: 1, target }) + target.source.runId = 'changed-after-acceptance' + await expect(command).resolves.toMatchObject({ open: true, target: { source: { runId: 'run' } } }) + expect(records).toHaveLength(1) + expect(changes).toMatchObject([{ kind: 'state', state: { open: true } }, { kind: 'record', status: 'failed', state: { open: true } }]) + expect(JSON.stringify(changes)).not.toContain('private transport') + await host.dispose() + }) +}) diff --git a/apps/buddy/electron/main/context-panel/registerContextPanelIpc.ts b/apps/buddy/electron/main/context-panel/registerContextPanelIpc.ts index d022fc88..e718eac5 100644 --- a/apps/buddy/electron/main/context-panel/registerContextPanelIpc.ts +++ b/apps/buddy/electron/main/context-panel/registerContextPanelIpc.ts @@ -14,13 +14,15 @@ export function registerContextPanelIpc(host: ContextPanelHost, getWindow: () => assertTrustedSender(event, getWindow()) return host.execute(contextPanelCommandSchema.parse(command)) }) - const stop = host.subscribe((state) => { + const subscription = host.onDidChange((change) => { + if (change.kind !== 'state') + return const window = getWindow() if (window && !window.isDestroyed()) - window.webContents.send(DESKTOP_IPC_CHANNELS.contextPanelStateChanged, state) + window.webContents.send(DESKTOP_IPC_CHANNELS.contextPanelStateChanged, change.state) }) return () => { - stop() + subscription.dispose() ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.contextPanelGetState) ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.contextPanelExecute) } diff --git a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts index af6395ce..f18bc50f 100644 --- a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts +++ b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' import { strFromU8, unzipSync } from 'fflate' import { describe, expect, it } from 'vitest' +import { DesktopDiagnosticLogger } from '../../desktopDiagnostics' import { createApplicationDiagnosticBundle } from '../applicationDiagnosticBundle' import { ApplicationLogReader } from '../ApplicationLogReader' @@ -25,6 +26,52 @@ function unpack(bytes: Uint8Array) { } describe('application diagnostic bundles', () => { + it('preserves space and directory identities through encoding, reading and export without admitting file details', async () => { + const directory = await createTemporaryDirectory('lexora-space-diagnostic-bundle-') + const logger = new DesktopDiagnosticLogger({ directory, appVersion: '0.9.2', userHome: '/fixture' }) + const identity = { spaceId: crypto.randomUUID(), directoryId: crypto.randomUUID(), operationId: crypto.randomUUID(), revision: 3 } + const fact = { ...identity, scope: 'local-service', level: 'error', event: 'space.file.response_denied', sourceId: 'fixture-private-source', message: 'fixture-private-local-detail', payload: { text: 'fixture-private-body', path: '/fixture/private-file' } } as const + expect(logger.record(fact)).toBe(true) + expect(logger.record({ ...fact, spaceId: '/fixture/private-space' })).toBe(false) + expect(logger.record({ ...fact, directoryId: '/fixture/private-directory' })).toBe(false) + await logger.close() + const reader = new ApplicationLogReader(directory, logger.launchId, '/fixture') + const page = await reader.query({}) + expect(page.skippedRecords).toBe(0) + const queried = page.records.find(record => record.event === fact.event) + expect(queried).toMatchObject(identity) + expect(queried).not.toHaveProperty('payload') + const bundle = unpack((await createApplicationDiagnosticBundle(reader, 'current'))!.bytes) + expect(bundle.errors.find(record => record.event === fact.event)).toMatchObject(identity) + expect(bundle.context.find(record => record.event === fact.event)).toMatchObject(identity) + expect(JSON.stringify({ errors: bundle.errors, context: bundle.context })).not.toContain('fixture-private') + }) + + it('preserves safe renderer identities and version facts through encoding, reading and export alongside older rows', async () => { + const directory = await createTemporaryDirectory('lexora-renderer-diagnostic-bundle-') + const logger = new DesktopDiagnosticLogger({ directory, appVersion: '0.9.2', userHome: '/fixture' }) + const workingCopyId = crypto.randomUUID() + const firstProducer = crypto.randomUUID() + const secondProducer = crypto.randomUUID() + const fact = { scope: 'desktop', level: 'error', event: 'workbench.copy.save_failed', workingCopyId, contentVersion: 3, savedVersion: 1, dirty: true, revision: 5, sourceSequence: 1, occurredAt: '2026-09-28T00:00:00.000Z' } as const + expect(logger.record({ ...fact, producerInstanceId: firstProducer, sourceId: 'fixture-private-renderer' })).toBe(true) + expect(logger.record({ ...fact, producerInstanceId: secondProducer })).toBe(true) + expect(logger.record({ scope: 'desktop', level: 'info', event: 'app.ready' })).toBe(true) + await logger.close() + const reader = new ApplicationLogReader(directory, logger.launchId, '/fixture') + const page = await reader.query({}) + expect(page.skippedRecords).toBe(0) + expect(page.records).toHaveLength(3) + const bundle = unpack((await createApplicationDiagnosticBundle(reader, 'current'))!.bytes) + expect(bundle.context.filter(record => record.workingCopyId === workingCopyId)).toMatchObject([ + { producerInstanceId: firstProducer, sourceSequence: 1, contentVersion: 3, savedVersion: 1, dirty: true, revision: 5 }, + { producerInstanceId: secondProducer, sourceSequence: 1, contentVersion: 3, savedVersion: 1, dirty: true, revision: 5 }, + ]) + expect(bundle.context.at(-1)).toMatchObject({ event: 'app.ready' }) + expect(JSON.stringify(bundle.context)).not.toContain('fixture-private') + expect(bundle.context.some(record => 'sourceId' in record)).toBe(false) + }) + it('keeps successful startup steps and the launch network failure when exporting a later incident', async () => { const { reader } = await fixture([ record(1, { event: 'network.start_failed', runId: undefined, level: 'warn', errorCode: 'NETWORK_START_FAILED', failure: { kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 } }), diff --git a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts index a6977c6b..344eeb76 100644 --- a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts +++ b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts @@ -19,6 +19,14 @@ const bundleRecordSchema = applicationLogRecordSchema.pick({ level: true, operationId: true, parentOperationId: true, + producerInstanceId: true, + extensionId: true, + workingCopyId: true, + markId: true, + revision: true, + contentVersion: true, + savedVersion: true, + dirty: true, generation: true, sessionId: true, providerId: true, @@ -27,6 +35,8 @@ const bundleRecordSchema = applicationLogRecordSchema.pick({ occurrenceId: true, toolCallId: true, conversationId: true, + spaceId: true, + directoryId: true, branchId: true, runId: true, turnId: true, diff --git a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts index b04e808f..66b5d206 100644 --- a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts +++ b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts @@ -67,7 +67,7 @@ export function encodeDiagnosticRecord( } : input.error === undefined ? undefined : { name: 'UnknownError', message: 'Non-Error failure' }, } - for (const key of ['parentOperationId', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) { + for (const key of ['parentOperationId', 'producerInstanceId', 'extensionId', 'workingCopyId', 'markId', 'revision', 'contentVersion', 'savedVersion', 'dirty', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'spaceId', 'directoryId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) { const parsed = applicationDiagnosticSchema.shape[key].safeParse(input[key]) if (!parsed.success) return null diff --git a/apps/buddy/electron/main/extensions/ExtensionProtocol.ts b/apps/buddy/electron/main/extensions/ExtensionProtocol.ts index 52012bea..5dc5568b 100644 --- a/apps/buddy/electron/main/extensions/ExtensionProtocol.ts +++ b/apps/buddy/electron/main/extensions/ExtensionProtocol.ts @@ -2,11 +2,11 @@ import type { Session } from 'electron' import type { ExtensionPackage, ExtensionPackageStore } from '../../../platform/extensions/ExtensionPackageStore' import { randomUUID } from 'node:crypto' import { extname } from 'node:path' +import hostSource from 'virtual:extension-runtime/host' +import viewSource from 'virtual:extension-runtime/view' import { extensionResourceRange } from '../../../platform/extensions/extensionResourceResponse' import { EXTENSION_PROTOCOL } from '../../../shared/extensions/extensionManifest' import { extensionResourceMimeType } from '../../../shared/extensions/extensionResources' -import hostSource from './runtime/host.js?raw' -import viewSource from './runtime/view.js?raw' export const extensionSchemePrivileges: Electron.CustomScheme = { scheme: EXTENSION_PROTOCOL, diff --git a/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts b/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts index 22cdc10f..db92d5a7 100644 --- a/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts +++ b/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts @@ -109,7 +109,7 @@ export class SandboxedExtensionHost implements ExtensionHost { throw new Error('EXTENSION_HOST_STOPPED') if (this.#pending.size >= 64) throw new Error('EXTENSION_REQUEST_LIMIT') - return this.#send(method, params, 15000) + return this.#send(method, params, method === 'agent.invoke' ? 125000 : 15000) } devtools(): void { diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts index e7b65de6..b2d0254a 100644 --- a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts @@ -28,7 +28,7 @@ it('discovers a compact host index and exact target contracts without loading pl expect(extensionPlacementSchema.parse({ ...placement, enabled: false })).toMatchObject({ height: detail.targets[0]!.height!.default, enabled: false }) expect(extensionPlacementSchema.safeParse({ ...placement, height: detail.targets[0]!.height!.max + 1 }).success).toBe(false) expect(extensionCapabilitiesSchema.parse(await query({ target: 'document.body' })).targets).toEqual([]) - expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['commands', 'workbench.panes', 'workbench.interactions']) + expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['events', 'agent.tools', 'agent.models', 'agent.task', 'settings', 'commands', 'workbench.panes', 'workbench.interactions']) await expect(query({ kind: 'arbitrary' })).rejects.toThrow() } finally { dispose() } diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 73ac0581..2e88b802 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -1,12 +1,16 @@ import type { BrowserWindow, IpcMainEvent } from 'electron' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' import type { ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi' +import type { JsonValue } from '../../../shared/workbench/workbenchState' import { join } from 'node:path' import { dialog, ipcMain, Notification, powerMonitor, session } from 'electron' import { z } from 'zod' import { ExtensionPackageStore } from '../../../platform/extensions/ExtensionPackageStore' import { ExtensionService } from '../../../platform/extensions/ExtensionService' +import { observeExtensionDiagnostics } from '../../../platform/extensions/observeExtensionDiagnostics' import { EXTENSION_IPC, extensionError, extensionManagementSchema } from '../../../shared/extensions/extensionApi' import { assertTrustedSender } from '../ipc' import { compileExtension } from './compileExtension' @@ -21,7 +25,10 @@ export function registerExtensionIpc(options: { get: (url: string, init: { signal: AbortSignal }) => Promise developmentDirectory?: string notificationsEnabled?: () => boolean -}): { dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise } { + agentChanged?: () => void + record?: ApplicationDiagnosticReporter + agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise +}): { dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version) const protocol = new ExtensionProtocol(store) const stopProtocol = protocol.install(session.defaultSession, 'view') @@ -44,6 +51,7 @@ export function registerExtensionIpc(options: { }, createView: pkg => protocol.register(pkg, 'view'), readText: options.readText, + agentRequest: options.agentRequest, get: options.get, compile: compileExtension, selectResources: async (name, selection, signal) => { @@ -86,11 +94,6 @@ export function registerExtensionIpc(options: { native.show() return true }, - changed: () => { - const current = window() - if (current && !current.isDestroyed()) - current.webContents.send(EXTENSION_IPC.changed) - }, workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => new Promise((resolve) => { const current = window() if (!current || current.isDestroyed() || signal.aborted || replies.size >= 64) { @@ -118,8 +121,20 @@ export function registerExtensionIpc(options: { current.webContents.send(EXTENSION_IPC.workbench, event) }), }) + const subscriptions = [ + service.onDidChange(() => { + const current = window() + if (current && !current.isDestroyed()) + current.webContents.send(EXTENSION_IPC.changed) + }), + service.onDidChange((change) => { + if (change.kind === 'contributions' && !change.initial) + options.agentChanged?.() + }), + observeExtensionDiagnostics(service, event => options.record?.(event)), + ] const resetHosts = () => { - void service.resetHosts() + void service.resetHosts().catch(() => {}) } const resetOnNavigation = (event: Electron.Event) => { if (event.isMainFrame && !event.isSameDocument) @@ -162,6 +177,9 @@ export function registerExtensionIpc(options: { await service.initialize() switch (input.action) { case 'list': return await service.list() + case 'configuration': return await service.configuration(input.id) + case 'configurationSnapshot': return await service.configurationSnapshot(input.id) + case 'configure': return await service.configure(input.id, input.patch) case 'installations': return service.installations.list() case 'catalog': return service.catalog.list(input.refresh) case 'reviewCatalog': return service.reviewCatalog(input.id, input.version) @@ -177,7 +195,7 @@ export function registerExtensionIpc(options: { case 'cancelInstall': return service.cancelInstall(input.token) case 'enable': return await service.enable(input.id, input.enabled) case 'restart': return await service.restart(input.id) - case 'uninstall': return await service.uninstall(input.id) + case 'uninstall': return await service.uninstall(input.id, input.clearData) case 'devtools': return await service.devtools(input.id) case 'revokeResources': return await service.revokeResources(input.id) case 'execute': return await service.execute(input.id, input.command, input.resource) @@ -219,23 +237,38 @@ export function registerExtensionIpc(options: { const dispose = async () => { powerMonitor.off('suspend', suspend) powerMonitor.off('resume', resume) - await service.dispose() - for (const reply of replies.values()) reply(null) - for (const current of bound) { - if (current.isDestroyed()) - continue - current.webContents.off('will-frame-navigate', guardNavigation) - current.webContents.off('did-start-navigation', resetOnNavigation) - current.webContents.off('render-process-gone', resetHosts) + try { + await service.dispose() + } + finally { + for (const subscription of subscriptions) subscription.dispose() + for (const reply of replies.values()) reply(null) + for (const current of bound) { + if (current.isDestroyed()) + continue + current.webContents.off('will-frame-navigate', guardNavigation) + current.webContents.off('did-start-navigation', resetOnNavigation) + current.webContents.off('render-process-gone', resetHosts) + } + stopProtocol() + ipcMain.removeHandler(EXTENSION_IPC.request) + ipcMain.removeHandler(EXTENSION_IPC.hostRequest) + ipcMain.off(EXTENSION_IPC.hostReply, onHostReply) + ipcMain.off(EXTENSION_IPC.workbenchReply, onWorkbenchReply) } - stopProtocol() - ipcMain.removeHandler(EXTENSION_IPC.request) - ipcMain.removeHandler(EXTENSION_IPC.hostRequest) - ipcMain.off(EXTENSION_IPC.hostReply, onHostReply) - ipcMain.off(EXTENSION_IPC.workbenchReply, onWorkbenchReply) } return { dispose, + agent: { + list: async () => { + await prepared + return service.agentContributions() + }, + invoke: async (input, signal) => { + await prepared + return service.invokeAgent(input, signal) + }, + }, inspect: async (id) => { await prepared return service.inspect(id) diff --git a/apps/buddy/electron/main/extensions/runtime/ExtensionHostEvents.ts b/apps/buddy/electron/main/extensions/runtime/ExtensionHostEvents.ts new file mode 100644 index 00000000..3df78fc0 --- /dev/null +++ b/apps/buddy/electron/main/extensions/runtime/ExtensionHostEvents.ts @@ -0,0 +1,48 @@ +import type { EventSubscription } from '../../../../shared/events/eventTypes' +import type { ExtensionHostEvents as HostEvents } from '../../../../shared/extensions/extensionEvents' +import type { ConfigurationEvents } from '../../../../shared/extensions/extensionSettings' +import type { WorkbenchPaneSnapshot } from '../../../../shared/workbench/workbenchInteraction' +import { EventBus } from '../../../../shared/events/EventBus' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' + +export class ExtensionHostEvents { + readonly #bus = new EventBus(() => console.error('EXTENSION_EVENT_LISTENER_FAILED')) + readonly events = this.#bus.subscriber + readonly #configurationAppliers = new Set<(configuration: ConfigurationEvents['configuration:changed']['configuration']) => unknown>() + #disposed = false + #panes: HostEvents['workbench:panes:changed']['panes'] = Object.freeze([]) + + get panes() { return this.#panes } + + updatePanes(panes: WorkbenchPaneSnapshot[]): void { + if (this.#disposed || JSON.stringify(this.#panes) === JSON.stringify(panes)) + return + this.#panes = copyEventSnapshot(panes) + this.#bus.emit({ type: 'workbench:panes:changed', data: { panes: this.#panes } }) + } + + registerConfigurationApplier(listener: (configuration: ConfigurationEvents['configuration:changed']['configuration']) => unknown): EventSubscription { + if (this.#disposed) + return { dispose() {} } + this.#configurationAppliers.add(listener) + return { dispose: () => { + this.#configurationAppliers.delete(listener) + } } + } + + async updateConfiguration(change: ConfigurationEvents['configuration:changed']): Promise { + if (this.#disposed) + return false + const appliers = [...this.#configurationAppliers] + const snapshot = copyEventSnapshot(change) + this.#bus.emit({ type: 'configuration:changed', data: snapshot }) + await Promise.all(appliers.map(async apply => apply(snapshot.configuration))) + return !this.#disposed && appliers.length > 0 + } + + dispose(): void { + this.#disposed = true + this.#configurationAppliers.clear() + this.#bus.dispose() + } +} diff --git a/apps/buddy/electron/main/extensions/runtime/ExtensionViewState.ts b/apps/buddy/electron/main/extensions/runtime/ExtensionViewState.ts new file mode 100644 index 00000000..93ddd2a5 --- /dev/null +++ b/apps/buddy/electron/main/extensions/runtime/ExtensionViewState.ts @@ -0,0 +1,147 @@ +import type { ExtensionViewEvents, ExtensionViewNotification } from '../../../../shared/extensions/extensionEvents' +import type { ExtensionViewCursor, ExtensionViewPolicy, ExtensionViewSnapshot, ExtensionViewSynchronization, ExtensionViewUpdate } from '../../../../shared/extensions/extensionViewProjection' +import { EventBus } from '../../../../shared/events/EventBus' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' +import { ownExtensionViewSnapshot, projectExtensionViewEvent } from '../../../../shared/extensions/extensionViewProjection' + +const BUFFER_LIMIT = 64 +export class ExtensionViewState { + readonly #bus = new EventBus(() => console.error('EXTENSION_EVENT_LISTENER_FAILED')) + readonly events = this.#bus.subscriber + readonly #resynchronize?: () => Promise + #policy: ExtensionViewPolicy = { decoration: false, control: false, interaction: false } + #state: ExtensionViewSnapshot = copyEventSnapshot({ workbench: { values: {}, pages: [] }, environment: { language: 'zh-CN', colorScheme: 'light', colors: {} }, visible: false, mount: null, anchor: null, control: null }) + #cursor: ExtensionViewCursor | null = null + #buffer: ExtensionViewUpdate[] = [] + #lost: ExtensionViewCursor | null = null + #sync: Promise | null = null + #attempts = 0 + #needsSync = false + #disposed = false + + constructor(resynchronize?: () => Promise) { + this.#resynchronize = resynchronize + } + + get snapshot() { return this.#state } + get synchronizationStatus() { return this.#sync ? 'resynchronizing' : this.#needsSync ? 'degraded' : this.#cursor ? 'current' : 'initializing' } + + initialize(state: ExtensionViewSnapshot, decoration: boolean, cursor?: ExtensionViewCursor, interaction = false): void { + if (this.#disposed) + return + this.#policy = Object.freeze({ decoration, control: state.control !== null, interaction }) + this.#state = ownExtensionViewSnapshot(state, this.#policy) + if (cursor) { + this.#install({ ...cursor, snapshot: state }, false) + this.#drain() + } + } + + accept(event: ExtensionViewNotification): void { + if (this.#disposed) + return + const projected = projectExtensionViewEvent(this.#state, event, this.#policy) + if (!projected) + return + this.#state = projected.snapshot + this.#bus.emit(projected.event) + } + + acceptUpdate(update: ExtensionViewUpdate): void { + if (this.#disposed || !update || typeof update.streamId !== 'string' || !Number.isSafeInteger(update.sequence) || update.sequence < 1) + return + if (this.#cursor?.streamId === update.streamId && update.sequence <= this.#cursor.sequence) + return + if (this.#buffer.some(event => event.streamId === update.streamId && event.sequence === update.sequence)) + return + if (!this.#sync && this.#attempts >= 3 && this.#buffer.every(event => event.streamId !== update.streamId || event.sequence < update.sequence)) + this.#attempts = 0 + this.#buffer.push(copyEventSnapshot(update) as ExtensionViewUpdate) + if (this.#buffer.length > BUFFER_LIMIT) { + const lost = this.#buffer.shift()! + this.#lost = { streamId: lost.streamId, sequence: lost.sequence } + } + if (this.#cursor) + this.#drain() + } + + dispose(): void { + this.#disposed = true + this.#buffer = [] + this.#bus.dispose() + } + + #install(value: ExtensionViewSynchronization, notify: boolean): void { + if (!value || typeof value.streamId !== 'string' || !Number.isSafeInteger(value.sequence) || value.sequence < 0) + throw new Error('EXTENSION_VIEW_SNAPSHOT_INVALID') + if (this.#cursor?.streamId === value.streamId && this.#cursor.sequence > value.sequence) + throw new Error('EXTENSION_VIEW_SNAPSHOT_STALE') + const previous = this.#state + const transient = notify && this.#cursor?.streamId === value.streamId + ? this.#buffer.filter(update => update.streamId === value.streamId && update.sequence <= value.sequence && ['view:message:received', 'interaction:activated', 'composer:input:received'].includes(update.event.type)).sort((a, b) => a.sequence - b.sequence) + : [] + this.#state = ownExtensionViewSnapshot(value.snapshot, this.#policy) + this.#cursor = Object.freeze({ streamId: value.streamId, sequence: value.sequence }) + this.#buffer = this.#buffer.filter(event => event.streamId === value.streamId && event.sequence > value.sequence) + if (this.#lost && (this.#lost.streamId !== value.streamId || this.#lost.sequence <= value.sequence)) + this.#lost = null + this.#needsSync = false + if (notify) + this.#notifyDifferences(previous) + for (const update of transient) this.accept(update.event) + } + + #drain(): void { + if (!this.#cursor || this.#disposed) + return + this.#buffer.sort((a, b) => a.sequence - b.sequence) + while (this.#buffer.length) { + const next = this.#buffer[0]! + if (next.streamId !== this.#cursor.streamId || next.sequence !== this.#cursor.sequence + 1) + break + this.#buffer.shift() + this.#cursor = Object.freeze({ streamId: next.streamId, sequence: next.sequence }) + this.#attempts = 0 + this.accept(next.event) + } + this.#needsSync = Boolean(this.#buffer.length || (this.#lost?.streamId === this.#cursor.streamId && this.#lost.sequence > this.#cursor.sequence)) + if (this.#needsSync) + this.#requestSynchronization() + else this.#attempts = 0 + } + + #requestSynchronization(): void { + if (this.#sync || !this.#resynchronize || this.#attempts >= 3 || this.#disposed) + return + this.#attempts++ + this.#sync = Promise.resolve().then(() => this.#resynchronize!()).then((snapshot) => { + if (this.#disposed) + return + this.#install(snapshot, true) + this.#drain() + }).catch(() => { this.#needsSync = true }).finally(() => { + this.#sync = null + if (this.#needsSync) + this.#requestSynchronization() + }) + } + + #notifyDifferences(previous: ExtensionViewSnapshot): void { + const current = this.#state + const publish = (key: keyof ExtensionViewSnapshot, event: ExtensionViewNotification) => { + if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) + this.#bus.emit(event) + } + publish('workbench', { type: 'workbench:context:changed', data: { context: current.workbench } }) + publish('environment', { type: 'view:environment:changed', data: { environment: current.environment } }) + publish('visible', { type: 'view:visibility:changed', data: { visible: current.visible } }) + const mount = current.mount ?? (previous.mount ? { ...previous.mount, visible: false } : null) + if (mount) + publish('mount', { type: 'view:mount:changed', data: { mount } }) + const anchor = current.anchor ?? (previous.anchor ? { ...previous.anchor, visible: false } : null) + if (anchor && this.#policy.decoration) + publish('anchor', { type: 'view:anchor:changed', data: { anchor } }) + if (current.control && this.#policy.control) + publish('control', { type: 'control:changed', data: { control: current.control } }) + } +} diff --git a/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts b/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts new file mode 100644 index 00000000..19711f3f --- /dev/null +++ b/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts @@ -0,0 +1,91 @@ +import type * as Sdk from '../../../../../service/resources/skills/plugin-creator/references/api' +import type { EventMessage, EventPattern, EventSubscriber } from '../../../../../shared/events/eventTypes' +import type { ExtensionHostEvents, ExtensionViewEvents } from '../../../../../shared/extensions/extensionEvents' +import { deferred } from '@buddy-tests/deferred' +import { expect, expectTypeOf, it } from 'vitest' +import { ExtensionHostEvents as HostSource } from '../ExtensionHostEvents' +import { ExtensionViewState } from '../ExtensionViewState' + +it('keeps the self-contained public SDK and internal event contracts equivalent', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toExtend>() + expectTypeOf>().toExtend>() +}) + +it('keeps passive configuration observers independent of explicit hot-apply participants', async () => { + const source = new HostSource() + const change = { configuration: { enabled: true, model: { providerId: 'test', modelId: 'text' } }, changedKeys: ['enabled'] } + expect(await source.updateConfiguration(change)).toBe(false) + const seen: unknown[] = [] + source.events.on(['configuration:*', 'configuration:changed'], ({ data }) => { + seen.push(data) + expect(Object.isFrozen(data.configuration.model)).toBe(true) + expect(Object.isFrozen(data.changedKeys)).toBe(true) + }) + expect(await source.updateConfiguration(change)).toBe(false) + expect(seen).toEqual([change]) + const pending = deferred() + const participant = source.registerConfigurationApplier(async (configuration) => { + expect(configuration).toEqual(change.configuration) + expect(Object.isFrozen(configuration.model)).toBe(true) + await pending.promise + }) + const applying = source.updateConfiguration(change) + pending.resolve() + expect(await applying).toBe(true) + participant.dispose() + expect(await source.updateConfiguration(change)).toBe(false) + source.dispose() + expect(await source.updateConfiguration(change)).toBe(false) +}) + +it('fails explicit configuration application and fences a late result after disposal', async () => { + const change = { configuration: { enabled: true }, changedKeys: ['enabled'] } + const source = new HostSource() + const failed = source.registerConfigurationApplier(() => { + throw new Error('apply failed') + }) + await expect(source.updateConfiguration(change)).rejects.toThrow('apply failed') + failed.dispose() + const pending = deferred() + source.registerConfigurationApplier(() => pending.promise) + const applying = source.updateConfiguration(change) + source.dispose() + pending.resolve() + expect(await applying).toBe(false) +}) + +it('updates snapshots before notifying, keeps frames isolated and gates restricted event domains', () => { + const state = new ExtensionViewState() + const other = new ExtensionViewState() + const initial = { workbench: { values: {}, pages: [] }, environment: { language: 'en-US', colorScheme: 'light' as const, colors: {} }, visible: false, mount: null, anchor: null, control: null } + state.initialize(initial, false) + const seen: string[] = [] + state.events.on(['view:**', 'workbench:**', 'control:**', 'composer:**'], (event) => { + seen.push(event.type) + if (event.type === 'view:visibility:changed') + expect(state.snapshot.visible).toBe(event.data.visible) + if (event.type === 'workbench:context:changed') { + expect(state.snapshot.workbench).toBe(event.data.context) + expect(Object.isFrozen(event.data.context.values)).toBe(true) + } + }) + other.events.on('**', () => seen.push('wrong-instance')) + state.accept({ type: 'view:visibility:changed', data: { visible: true } }) + state.accept({ type: 'view:visibility:changed', data: { visible: true } }) + state.accept({ type: 'workbench:context:changed', data: { context: { values: { page: 'tasks' }, pages: [] } } }) + state.accept({ type: 'composer:input:received', data: {} }) + state.accept({ type: 'control:changed', data: { control: { revision: '1', value: null, disabled: false, options: [] } } }) + state.accept({ type: 'view:anchor:changed', data: { anchor: { kind: 'composer.input', visible: true, width: 100, height: 40 } } }) + expect(seen).toEqual(['view:visibility:changed', 'workbench:context:changed']) + expect(initial.visible).toBe(false) + state.initialize(initial, true) + state.accept({ type: 'composer:input:received', data: { caret: null } }) + expect(seen.at(-1)).toBe('composer:input:received') + state.dispose() + state.accept({ type: 'view:message:received', data: { message: { ignored: true } } }) + expect(seen).toHaveLength(3) +}) diff --git a/apps/buddy/electron/main/extensions/runtime/host.js b/apps/buddy/electron/main/extensions/runtime/host.js index c2b90f11..1abc0c3a 100644 --- a/apps/buddy/electron/main/extensions/runtime/host.js +++ b/apps/buddy/electron/main/extensions/runtime/host.js @@ -1,18 +1,18 @@ +import { ExtensionHostEvents } from './ExtensionHostEvents' + const bridge = window.lexoraExtensionHost const commands = new Map() +const agentTools = new Map() +const agentInvocations = new Map() +const source = new ExtensionHostEvents() +const events = source.events const subscriptions = new Set() let entry let manifest let context let registrationError = null let activated = false -let panes = Object.freeze([]) -const paneListeners = new Set() const interactions = new Map() -function applyPanes(next) { - panes = Object.freeze(next.map(pane => Object.freeze({ ...pane, rect: Object.freeze(pane.rect) }))) - for (const listener of paneListeners) listener(panes) -} const code = error => /^EXTENSION_[A-Z_]+$/.test(error?.message) ? error.message : 'EXTENSION_ACTIVATION_FAILED' const request = (method, params = null) => bridge.request(method, params) function disposable(cleanup) { @@ -32,10 +32,11 @@ bridge.subscribe(async ({ id, method, params }) => { let result = null if (method === 'activate') { manifest = params.manifest - applyPanes(params.panes ?? []) + source.updatePanes(params.panes ?? []) entry = manifest.entry ? await import(`/__package/${manifest.entry}`) : {} context = Object.freeze({ extension: Object.freeze({ id: manifest.id, version: manifest.version, apiVersion: manifest.apiVersion }), + events, subscriptions: { add: (value) => { subscriptions.add(value) return value @@ -49,11 +50,8 @@ bridge.subscribe(async ({ id, method, params }) => { return disposable(() => commands.delete(id)) } }, workbench: { - get panes() { return panes }, - onPanesChange: (listener) => { - paneListeners.add(listener) - return disposable(() => paneListeners.delete(listener)) - }, + get panes() { return source.panes }, + onPanesChange: listener => events.on('workbench:panes:changed', event => listener(event.data.panes)), }, interactions: { start: async (title) => { const id = crypto.randomUUID() @@ -75,6 +73,18 @@ bridge.subscribe(async ({ id, method, params }) => { placements: { show: (id, options) => request('placements.show', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }), hide: (id, options) => request('placements.hide', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }) }, resources: { readText: resource => request('resources.readText', { id: resource.id }) }, storage: { get: () => request('storage.get'), set: value => request('storage.set', { value, version: manifest.dataVersion }) }, + configuration: { + get: () => request('configuration.get'), + onChange: listener => source.registerConfigurationApplier(listener), + }, + agent: { registerTool(id, callback) { + if (activated || !manifest.contributes.agent?.tools.some(tool => tool.id === id) || agentTools.has(id) || typeof callback !== 'function') { + registrationError = new Error('EXTENSION_TOOL_INVALID') + throw registrationError + } + agentTools.set(id, callback) + return disposable(() => agentTools.delete(id)) + } }, network: { get: url => request('network.get', { url }) }, notifications: { show: notification => request('notifications.show', notification) }, schedules: { @@ -99,24 +109,58 @@ bridge.subscribe(async ({ id, method, params }) => { throw registrationError if (manifest.contributes.commands.some(command => !commands.has(command.id))) throw new Error('EXTENSION_COMMAND_MISSING') + if (manifest.contributes.agent?.tools.some(tool => !agentTools.has(tool.id))) + throw new Error('EXTENSION_TOOL_MISSING') activated = true } + else if (method === 'configuration.changed') { + if (!activated) + throw new Error('EXTENSION_HOST_STOPPED') + try { + const applied = await source.updateConfiguration({ configuration: params.configuration, changedKeys: params.changedKeys }) + result = { operationId: params.operationId, generation: params.generation, configurationRevision: params.configurationRevision, applied } + } + catch { throw new Error('EXTENSION_CONFIGURATION_UPDATE_FAILED') } + } + else if (method === 'agent.invoke') { + if (!activated || !agentTools.has(params.tool)) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + const controller = new AbortController() + agentInvocations.set(params.invocationId, controller) + const call = (method, value = null) => { + controller.signal.throwIfAborted() + return request('agent.request', { invocationId: params.invocationId, method, params: value }) + } + try { + result = await agentTools.get(params.tool)(params.input, Object.freeze({ + signal: controller.signal, + task: Object.freeze({ get: () => call('task.get'), rename: value => call('task.rename', value) }), + models: Object.freeze({ generateText: value => call('models.generateText', value) }), + })) ?? null + } + finally { agentInvocations.delete(params.invocationId) } + } + else if (method === 'agent.cancel') { + agentInvocations.get(params.invocationId)?.abort() + } else if (method === 'command') { if (!activated || !commands.has(params.command)) throw new Error('EXTENSION_COMMAND_UNAVAILABLE') result = await commands.get(params.command)(Object.freeze({ resource: params.resource, arguments: params.arguments ?? null, invocation: params.invocation ?? null })) ?? null } else if (method === 'panes') { - applyPanes(params.panes) + source.updatePanes(params.panes) } else if (method === 'interactionEnded') { interactions.get(params.id)?.abort() interactions.delete(params.id) } else if (method === 'deactivate') { + for (const controller of agentInvocations.values()) controller.abort() + agentInvocations.clear() for (const controller of interactions.values()) controller.abort() interactions.clear() - paneListeners.clear() + source.dispose() activated = false try { await entry?.deactivate?.() diff --git a/apps/buddy/electron/main/extensions/runtime/view.js b/apps/buddy/electron/main/extensions/runtime/view.js index f9736281..708ce7b5 100644 --- a/apps/buddy/electron/main/extensions/runtime/view.js +++ b/apps/buddy/electron/main/extensions/runtime/view.js @@ -1,34 +1,18 @@ +import { ExtensionViewState } from './ExtensionViewState' + const token = location.hostname const pending = new Map() -let environment = { language: 'zh-CN', colorScheme: 'light', colors: {} } -let workbench = Object.freeze({ values: Object.freeze({}), pages: Object.freeze([]) }) -let visible = false -const workbenchListeners = new Set() -const visibilityListeners = new Set() -function applyWorkbench(next) { - workbench = Object.freeze({ values: Object.freeze(next.values), pages: Object.freeze(next.pages.map(page => Object.freeze(page))) }) - for (const listener of workbenchListeners) listener(workbench) -} -let mount = null -let anchor = null -let control = null +const state = new ExtensionViewState(() => request('events.snapshot')) +const events = state.events let overlay = false let interactionId = null -const activationListeners = new Set() -const messageListeners = new Set() -const environmentListeners = new Set() -const mountListeners = new Set() -const anchorListeners = new Set() -const controlListeners = new Set() -const activityListeners = new Set() function applyEnvironment(next) { - environment = Object.freeze(next) document.documentElement.lang = next.language document.documentElement.style.colorScheme = next.colorScheme for (const [name, value] of Object.entries(next.colors)) document.documentElement.style.setProperty(`--lexora-${name}`, value) - for (const listener of environmentListeners) listener(environment) } +events.on('view:environment:changed', event => applyEnvironment(event.data.environment)) function request(method, params = null) { return new Promise((resolve, reject) => { if (pending.size >= 64) @@ -50,46 +34,8 @@ addEventListener('message', (event) => { parent.postMessage({ channel: 'lexora-extension', token, pong: data.ping }, '*') return } - if (data.activation) { - for (const listener of activationListeners) listener(Object.freeze(data.activation)) - return - } - if (Object.hasOwn(data, 'message')) { - for (const listener of messageListeners) listener(data.message) - return - } - if (data.workbench) { - applyWorkbench(data.workbench) - return - } - if (typeof data.visible === 'boolean') { - if (visible !== data.visible) { - visible = data.visible - for (const listener of visibilityListeners) listener(visible) - } - return - } - if (data.environment) { - applyEnvironment(data.environment) - return - } - if (data.mount) { - mount = Object.freeze(data.mount) - for (const listener of mountListeners) listener(mount) - return - } - if (data.anchor) { - anchor = Object.freeze(data.anchor) - for (const listener of anchorListeners) listener(anchor) - return - } - if (data.control) { - control = Object.freeze(data.control) - for (const listener of controlListeners) listener(control) - return - } - if (overlay && data.activity?.type === 'composer-input') { - for (const listener of activityListeners) listener(Object.freeze(data.activity)) + if (data.event) { + state.acceptUpdate(data) return } const item = pending.get(data.id) @@ -106,7 +52,7 @@ addEventListener('keydown', (event) => { event.preventDefault() parent.postMessage({ channel: 'lexora-extension', token, endInteraction: true }, '*') } - if (control && event.isTrusted && event.key === 'Escape') + if (state.snapshot.control && event.isTrusted && event.key === 'Escape') parent.postMessage({ channel: 'lexora-extension', token, dismiss: true }, '*') }) addEventListener('pagehide', () => { @@ -115,15 +61,14 @@ addEventListener('pagehide', () => { item.reject(new Error('EXTENSION_VIEW_CLOSED')) } pending.clear() + state.dispose() }) async function initialize() { try { const initial = await request('bootstrap') interactionId = initial.interactionId overlay = initial.presentation === 'decoration' - anchor = initial.anchor ?? anchor - mount = initial.mount ?? mount - control = initial.control + state.initialize({ workbench: initial.workbench, visible: initial.visible, environment: initial.environment, anchor: initial.anchor ?? null, mount: initial.mount ?? null, control: initial.control }, overlay, initial.eventCursor, !!interactionId) if (overlay || initial.presentation === 'control' || initial.presentation === 'slot' || initial.location === 'mount') { const style = document.createElement('style') style.textContent = 'html,body{height:100%;background:transparent}body>main{height:100%;box-sizing:border-box;padding:0}' @@ -133,42 +78,35 @@ async function initialize() { document.documentElement.style.overflow = 'hidden' document.body.style.overflow = 'hidden' } - applyWorkbench(initial.workbench) - visible = initial.visible - applyEnvironment(initial.environment) + applyEnvironment(state.snapshot.environment) const entry = await import(`/__package/${initial.entry}`) const controller = new AbortController() addEventListener('pagehide', () => controller.abort(), { once: true }) - const subscribe = (listeners, listener) => { - listeners.add(listener) - const dispose = () => listeners.delete(listener) - controller.signal.addEventListener('abort', dispose, { once: true }) - return { dispose } - } const api = Object.freeze({ apiVersion: initial.apiVersion, + events, instanceId: initial.instanceId, - interaction: interactionId ? Object.freeze({ id: interactionId, setRegions: regions => request('interaction.setRegions', regions), onActivate: listener => subscribe(activationListeners, listener) }) : null, - onMessage: listener => subscribe(messageListeners, listener), - get workbench() { return workbench }, - onWorkbenchChange: listener => subscribe(workbenchListeners, listener), - get visible() { return visible }, - onVisibilityChange: listener => subscribe(visibilityListeners, listener), - get environment() { return environment }, - onEnvironmentChange: listener => subscribe(environmentListeners, listener), - get mount() { return mount }, - onMountChange: listener => subscribe(mountListeners, listener), - get anchor() { return anchor }, + interaction: interactionId ? Object.freeze({ id: interactionId, setRegions: regions => request('interaction.setRegions', regions), onActivate: listener => events.on('interaction:activated', ({ data }) => listener({ id: data.regionId, x: data.x, y: data.y })) }) : null, + onMessage: listener => events.on('view:message:received', event => listener(event.data.message)), + get workbench() { return state.snapshot.workbench }, + onWorkbenchChange: listener => events.on('workbench:context:changed', event => listener(event.data.context)), + get visible() { return state.snapshot.visible }, + onVisibilityChange: listener => events.on('view:visibility:changed', event => listener(event.data.visible)), + get environment() { return state.snapshot.environment }, + onEnvironmentChange: listener => events.on('view:environment:changed', event => listener(event.data.environment)), + get mount() { return state.snapshot.mount }, + onMountChange: listener => events.on('view:mount:changed', event => listener(event.data.mount)), + get anchor() { return state.snapshot.anchor }, onAnchorChange: (listener) => { if (!overlay) throw new Error('EXTENSION_METHOD_DENIED') - return subscribe(anchorListeners, listener) + return events.on('view:anchor:changed', event => listener(event.data.anchor)) }, control: initial.presentation === 'control' ? Object.freeze({ - get snapshot() { return control }, - onChange: listener => subscribe(controlListeners, listener), - propose: (value, revision = control?.revision) => request('control.propose', { value, revision }), + get snapshot() { return state.snapshot.control }, + onChange: listener => events.on('control:changed', event => listener(event.data.control)), + propose: (value, revision = state.snapshot.control?.revision) => request('control.propose', { value, revision }), }) : null, resource: initial.resource, @@ -179,7 +117,7 @@ async function initialize() { onActivity: (listener) => { if (!overlay) throw new Error('EXTENSION_METHOD_DENIED') - return subscribe(activityListeners, listener) + return events.on('composer:input:received', event => listener({ type: 'composer-input', ...event.data })) }, setState: state => request('view.setState', state), setActive: active => request('view.setActive', { active }), diff --git a/apps/buddy/electron/main/local-chat/notifications.ts b/apps/buddy/electron/main/local-chat/notifications.ts index 2122450c..c9d7d397 100644 --- a/apps/buddy/electron/main/local-chat/notifications.ts +++ b/apps/buddy/electron/main/local-chat/notifications.ts @@ -1,11 +1,19 @@ import type { BrowserWindow } from 'electron' import type { RegisterLocalChatIpcOptions } from './registrar' +import { artifactsChanged } from '../../../shared/artifacts/artifactApi' import { automationNotifications } from '../../../shared/automation/automationApi' +import { changesChanged } from '../../../shared/changes/changeApi' +import { connectorNotifications } from '../../../shared/connectors/connectorApi' +import { composerResourcesChanged } from '../../../shared/conversation/composerApi' +import { CONVERSATION_CHANGED, conversationSchema } from '../../../shared/conversation/conversationApi' +import { webSettingsChanged } from '../../../shared/network/webApi' +import { notificationsChanged } from '../../../shared/notifications/notificationApi' import { providerNotifications } from '../../../shared/providers/providerApi' import { toPublicRunEvent } from '../../../shared/runs/publicRunEvent' import { runNotifications } from '../../../shared/runs/runApi' import { runtimeResponseSchemas } from '../../../shared/runtime/serviceState' import { skillChangedSchema } from '../../../shared/skills/skillApi' +import { spaceChanged } from '../../../shared/spaces/spaceApi' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' export function registerLocalChatNotifications(options: RegisterLocalChatIpcOptions): () => void { @@ -15,6 +23,49 @@ export function registerLocalChatNotifications(options: RegisterLocalChatIpcOpti sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.runtimeStateChanged, parsed.data) }) const stopNotificationSubscription = options.runtime.onNotification((notification) => { + if (notification.method === artifactsChanged.method || notification.method === changesChanged.method) { + const contract = notification.method === artifactsChanged.method ? artifactsChanged : changesChanged + const event = contract.params.safeParse(notification.params) + if (event.success) + sendToRenderer(options.getWindow(), contract === artifactsChanged ? LOCAL_CHAT_IPC_CHANNELS.artifactsChanged : LOCAL_CHAT_IPC_CHANNELS.changesChanged, event.data) + return + } + if (notification.method === spaceChanged.method) { + const event = spaceChanged.params.safeParse(notification.params) + if (event.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.spacesChanged, event.data) + return + } + if (notification.method === notificationsChanged.method) { + const result = notificationsChanged.params.safeParse(notification.params) + if (result.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.notificationsChanged, result.data) + return + } + if (notification.method === composerResourcesChanged.method) { + const result = composerResourcesChanged.params.safeParse(notification.params) + if (result.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.composerResourcesChanged, result.data) + return + } + if (notification.method === webSettingsChanged.method) { + const changed = webSettingsChanged.params.safeParse(notification.params) + if (changed.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.webSettingsChanged, changed.data) + return + } + if (notification.method === CONVERSATION_CHANGED) { + const result = conversationSchema.safeParse(notification.params) + if (result.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.conversationsChanged, result.data) + return + } + if (notification.method === connectorNotifications.changed.method) { + const result = connectorNotifications.changed.params.safeParse(notification.params) + if (result.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.connectorsChanged, result.data) + return + } if (notification.method === 'skills.changed') { const result = skillChangedSchema.safeParse(notification.params) if (result.success) @@ -43,6 +94,12 @@ export function registerLocalChatNotifications(options: RegisterLocalChatIpcOpti } return } + if (notification.method === providerNotifications.changed.method) { + const changed = providerNotifications.changed.params.safeParse(notification.params) + if (changed.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.providersChanged, changed.data) + return + } if (notification.method === providerNotifications.authChallenge.method) { const challenge = providerNotifications.authChallenge.params.safeParse(notification.params) if (challenge.success) { diff --git a/apps/buddy/electron/main/network/DesktopNetwork.ts b/apps/buddy/electron/main/network/DesktopNetwork.ts index 10c579b8..482e0c06 100644 --- a/apps/buddy/electron/main/network/DesktopNetwork.ts +++ b/apps/buddy/electron/main/network/DesktopNetwork.ts @@ -4,12 +4,28 @@ import type { ProxySettings } from '../../../shared/network/proxySettings' import { randomUUID } from 'node:crypto' import { app, session } from 'electron' import { NetworkStartupError } from '../../../shared/diagnostics/networkStartupFailure' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { OutboundProxy, toElectronProxyConfig } from './OutboundProxy' import { requestThroughHost } from './requestThroughHost' const UNAVAILABLE_PROXY_URL = 'http://127.0.0.1:0' +export interface DesktopNetworkChange { + readonly kind: 'lifecycle' | 'configuration' | 'session' + readonly revision: number + readonly operationId: string + readonly status: 'starting' | 'ready' | 'degraded' | 'applied' | 'failed' | 'unavailable' | 'stopping' | 'stopped' + readonly mode: ProxySettings['mode'] | null + readonly failure?: NetworkStartupFailure +} + export class DesktopNetwork { + readonly #changes = new Emitter(() => console.error('NETWORK_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #status: 'idle' | 'starting' | 'ready' | 'degraded' | 'stopping' | 'stopped' | 'stop-failed' = 'idle' + #stopPromise: Promise | undefined readonly #resolver = session.fromPartition(`lexora-proxy-resolver:${randomUUID()}`, { cache: false }) readonly #proxy = new OutboundProxy(url => this.#resolve(url)) #settings: ProxySettings | null = null @@ -17,9 +33,11 @@ export class DesktopNetwork { #sessionConfig: ProxyConfig | null = null readonly #sessionSetup = new Set>() #sessionFailure: unknown + readonly #failedSessions = new Map() #startupError: NetworkStartupError | null = null get startupError(): NetworkStartupError | null { return this.#startupError } + get snapshot() { return copyEventSnapshot({ revision: this.#revision, status: this.#status, mode: this.#settings?.mode ?? null, pendingSessions: this.#sessionSetup.size, failedSessions: this.#failedSessions.size, failure: this.#startupError?.failure ?? null }) } get proxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.url } get sandboxProxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.sandboxUrl } @@ -48,13 +66,31 @@ export class DesktopNetwork { readonly #onSessionCreated = (created: Session) => { if (created === this.#resolver) return - if (!this.#sessionConfig) + if (!this.#sessionConfig || this.#stopPromise) return - const setup = created.setProxy(this.#sessionConfig) - this.#sessionSetup.add(setup) - void setup.catch((error: unknown) => { + this.#configureSession(created) + } + + #configureSession(created: Session): Promise { + const operationId = randomUUID() + const config = { ...this.#sessionConfig! } + const setup = Promise.resolve().then(() => created.setProxy(config)).then(() => { + this.#sessionSetup.delete(setup) + this.#failedSessions.delete(created) + this.#sessionFailure = [...this.#failedSessions.values()][0] + if (!this.#startupError && !this.#failedSessions.size && this.#status === 'degraded') + this.#status = 'ready' + this.#publish('session', 'applied', operationId) + }).catch((error: unknown) => { + this.#sessionSetup.delete(setup) + this.#failedSessions.set(created, error) this.#sessionFailure = error - }).finally(() => this.#sessionSetup.delete(setup)) + if (this.#status !== 'stopping') + this.#status = 'degraded' + this.#publish('session', 'failed', operationId, new NetworkStartupError('configure_sessions', error).failure) + }) + this.#sessionSetup.add(setup) + return setup } readonly #onLogin = (event: Event, _contents: WebContents | null, _details: AuthenticationResponseDetails, authInfo: AuthInfo, callback: (username?: string, password?: string) => void) => { @@ -65,6 +101,11 @@ export class DesktopNetwork { } async start(settings: ProxySettings): Promise { + if (this.#status !== 'idle' || this.#stopPromise) + throw new Error('NETWORK_ALREADY_STARTED') + this.#status = 'starting' + const operationId = randomUUID() + this.#publish('lifecycle', 'starting', operationId) let operation: NetworkStartupFailure['operation'] = 'configure_upstream' try { await this.apply(settings) @@ -74,6 +115,8 @@ export class DesktopNetwork { catch (cause) { this.#startupError = new NetworkStartupError(operation, cause) this.#proxy.disconnect() + this.#status = 'degraded' + this.#publish('lifecycle', 'degraded', operationId, this.#startupError.failure) } this.#sessionConfig = { mode: 'fixed_servers', proxyRules: this.#startupError ? this.proxyUrl : this.#proxy.address, proxyBypassRules: '<-loopback>' } app.on('login', this.#onLogin) @@ -82,24 +125,49 @@ export class DesktopNetwork { await Promise.all([app.setProxy(this.#sessionConfig), session.defaultSession.setProxy(this.#sessionConfig)]) } catch (cause) { - throw new NetworkStartupError('configure_sessions', cause) + this.#startupError = new NetworkStartupError('configure_sessions', cause) + this.#status = 'degraded' + this.#publish('lifecycle', 'failed', operationId, this.#startupError.failure) + throw this.#startupError } + this.#status = this.#startupError || this.#sessionFailure ? 'degraded' : 'ready' + if (!this.#startupError) + this.#publish('lifecycle', this.#status, operationId) } async apply(settings: ProxySettings): Promise { - if (this.#startupError) - return - if (settings.mode === this.#settings?.mode && settings.server === this.#settings.server) - return + if (this.#stopPromise || this.#status === 'stopped') + throw new Error('NETWORK_STOPPED') + const input = { ...settings } const operation = this.#updating.then(async () => { - await this.#resolver.setProxy(toElectronProxyConfig(settings)) - this.#settings = { ...settings } - this.#proxy.disconnect() + const operationId = randomUUID() + if (this.#startupError) { + this.#publish('configuration', 'unavailable', operationId, this.#startupError.failure) + return + } + if (input.mode !== this.#settings?.mode || input.server !== this.#settings.server) { + try { + await this.#resolver.setProxy(toElectronProxyConfig(input)) + this.#settings = input + this.#proxy.disconnect() + this.#publish('configuration', 'applied', operationId) + } + catch (cause) { + this.#publish('configuration', 'failed', operationId, new NetworkStartupError('configure_upstream', cause).failure) + throw cause + } + } + await this.reconcileSessions() }) this.#updating = operation.catch(() => {}) await operation } + async reconcileSessions(): Promise { + if (this.#sessionConfig && !this.#stopPromise) + await Promise.all([...this.#failedSessions.keys()].map(current => this.#configureSession(current))) + } + async #resolve(url: string): Promise { await this.#updating if (this.#startupError) @@ -109,11 +177,38 @@ export class DesktopNetwork { return this.#resolver.resolveProxy(url) } - async stop(): Promise { + stop(): Promise { + this.#stopPromise ??= Promise.resolve().then(() => this.#stop()) + return this.#stopPromise + } + + async #stop(): Promise { + const operationId = randomUUID() + this.#status = 'stopping' + this.#publish('lifecycle', 'stopping', operationId) app.off('session-created', this.#onSessionCreated) app.off('login', this.#onLogin) - await Promise.allSettled(this.#sessionSetup) - await this.#proxy.stop() - await this.#resolver.closeAllConnections() + try { + await this.#updating + await Promise.allSettled(this.#sessionSetup) + const resources = await Promise.allSettled([this.#proxy.stop(), this.#resolver.closeAllConnections()]) + const failures = resources.filter(result => result.status === 'rejected') + if (failures.length) + throw new AggregateError(failures.map(result => result.reason), 'NETWORK_STOP_FAILED') + this.#status = 'stopped' + this.#publish('lifecycle', 'stopped', operationId) + } + catch (error) { + this.#status = 'stop-failed' + this.#publish('lifecycle', 'failed', operationId) + throw error + } + finally { + this.#changes.dispose() + } + } + + #publish(kind: DesktopNetworkChange['kind'], status: DesktopNetworkChange['status'], operationId: string, failure?: NetworkStartupFailure): void { + this.#changes.fire(copyEventSnapshot({ kind, status, operationId, revision: ++this.#revision, mode: this.#settings?.mode ?? null, ...(failure ? { failure } : {}) })) } } diff --git a/apps/buddy/electron/main/network/__tests__/DesktopNetwork.spec.ts b/apps/buddy/electron/main/network/__tests__/DesktopNetwork.spec.ts new file mode 100644 index 00000000..4fa86630 --- /dev/null +++ b/apps/buddy/electron/main/network/__tests__/DesktopNetwork.spec.ts @@ -0,0 +1,74 @@ +import type { DesktopNetworkChange } from '../DesktopNetwork' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DesktopNetwork } from '../DesktopNetwork' + +const mocks = vi.hoisted(() => ({ + handlers: new Map void>(), + resolver: { setProxy: vi.fn(async () => {}), resolveProxy: vi.fn(async () => 'DIRECT'), closeAllConnections: vi.fn(async () => {}) }, + defaultSession: { setProxy: vi.fn(async () => {}) }, + start: vi.fn(async () => {}), + stop: vi.fn(async () => {}), +})) +vi.mock('electron', () => ({ + app: { + on: (event: string, handler: (...args: unknown[]) => void) => mocks.handlers.set(event, handler), + off: (event: string) => mocks.handlers.delete(event), + setProxy: vi.fn(async () => {}), + }, + session: { fromPartition: () => mocks.resolver, defaultSession: mocks.defaultSession }, +})) +vi.mock('../OutboundProxy', () => ({ + OutboundProxy: class { + url = 'http://fixture.invalid' + sandboxUrl = this.url + address = this.url + start = mocks.start + stop = mocks.stop + disconnect() {} + }, + toElectronProxyConfig: (value: unknown) => value, +})) +afterEach(() => { + vi.clearAllMocks() + + mocks.handlers.clear() +}) + +describe('desktop network state ownership', () => { + it('reports degraded startup without exposing the upstream error message', async () => { + mocks.start.mockRejectedValueOnce(new Error('private-upstream-token')) + const network = new DesktopNetwork() + const facts: DesktopNetworkChange[] = [] + network.onDidChange(change => facts.push(change)) + await network.start({ mode: 'system', server: '' }) + expect(network.snapshot).toMatchObject({ status: 'degraded' }) + expect(facts.at(-1)).toMatchObject({ kind: 'lifecycle', status: 'degraded' }) + expect(JSON.stringify(facts)).not.toContain('private-upstream-token') + await network.stop() + expect(facts.at(-1)).toMatchObject({ status: 'stopped' }) + }) + + it('recovers a failed new-session configuration through explicit reconciliation', async () => { + const network = new DesktopNetwork() + const facts: DesktopNetworkChange[] = [] + network.onDidChange(change => facts.push(change)) + await network.start({ mode: 'system', server: '' }) + const created = { setProxy: vi.fn().mockRejectedValueOnce(new Error('private-proxy-error')).mockResolvedValue(undefined) } + mocks.handlers.get('session-created')!(created) + await vi.waitFor(() => expect(network.snapshot.failedSessions).toBe(1)) + expect(network.snapshot).toMatchObject({ status: 'degraded', pendingSessions: 0 }) + await network.reconcileSessions() + expect(network.snapshot).toMatchObject({ status: 'ready', failedSessions: 0, pendingSessions: 0 }) + expect(facts.filter(change => change.kind === 'session').map(change => change.status)).toEqual(['failed', 'applied']) + await network.stop() + }) + + it('retains a failed-stop snapshot when the proxy close rejects', async () => { + const network = new DesktopNetwork() + await network.start({ mode: 'direct', server: '' }) + mocks.stop.mockRejectedValueOnce(new Error('close failed')) + await expect(network.stop()).rejects.toThrow('NETWORK_STOP_FAILED') + expect(network.snapshot.status).toBe('stop-failed') + expect(mocks.resolver.closeAllConnections).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/buddy/electron/main/pet/NativePetEvents.ts b/apps/buddy/electron/main/pet/NativePetEvents.ts new file mode 100644 index 00000000..742eb0ef --- /dev/null +++ b/apps/buddy/electron/main/pet/NativePetEvents.ts @@ -0,0 +1,10 @@ +import type { NativePetSupervisorState } from './NativePetSupervisor' + +export type NativePetFact + = | { readonly kind: 'state', readonly state: Readonly } + | { readonly kind: 'process', readonly status: 'spawn-failed' | 'exited' | 'stop-requested' | 'stop-unknown' } + | { readonly kind: 'configuration', readonly status: 'reload-requested' } + | { readonly kind: 'step', readonly operationId: string, readonly stepId: string, readonly index: number, readonly action: 'playAction' | 'moveByPath', readonly status: 'dispatched' | 'completed' | 'interrupted' | 'failed' | 'unknown', readonly completedSteps: number } + | { readonly kind: 'recovery', readonly operationId: string, readonly status: 'requested' | 'completed' | 'failed' | 'unknown' } + +export type NativePetChange = NativePetFact & { readonly revision: number, readonly generation: string | null } diff --git a/apps/buddy/electron/main/pet/NativePetSupervisor.ts b/apps/buddy/electron/main/pet/NativePetSupervisor.ts index 89e695dc..415cf131 100644 --- a/apps/buddy/electron/main/pet/NativePetSupervisor.ts +++ b/apps/buddy/electron/main/pet/NativePetSupervisor.ts @@ -4,12 +4,14 @@ import type { PetExecuteSequenceResult, PetPrimitiveStep, } from '../../../shared/runtime/petProtocol' +import type { NativePetChange, NativePetFact } from './NativePetEvents' import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import process from 'node:process' - +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { petExecuteSequenceParamsSchema } from '../../../shared/runtime/petProtocol' import { NativePetProtocolError, @@ -60,6 +62,7 @@ export interface NativePetChildProcess { export type NativePetSupervisorState = { status: 'stopped' } + | { status: 'stopping' | 'stop-unknown' } | { status: 'starting' | 'ready' | 'restarting', pid?: number, restartAttempt: number } | { status: 'offline', restartAttempt: number } @@ -71,21 +74,33 @@ export interface NativePetSupervisorOptions { restartDelaysMs?: readonly number[] spawnPet: () => NativePetChildProcess stableResetMs?: number + stopTimeoutMs?: number } interface ActiveSequence { + operationId: string currentStepId: string | null priority: number promise: Promise } interface NativePetGeneration { + identity: string + exited: Promise + resolveExit: () => void client: NativePetRpcClient id: number process: NativePetChildProcess } export class NativePetSupervisor { + readonly #changes = new Emitter(() => console.error('PET_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + readonly #retiring = new Set() + readonly #stopTimeoutMs: number + #stopPromise: Promise | undefined + #disposing: Promise | undefined readonly #captureStderr?: (output: NodeJS.ReadableStream) => void readonly #diagnosticOutput: Writable readonly #onOpenDesktop?: () => void @@ -111,21 +126,31 @@ export class NativePetSupervisor { this.#restartDelaysMs = options.restartDelaysMs ?? DEFAULT_RESTART_DELAYS_MS this.#spawnPet = options.spawnPet this.#stableResetMs = options.stableResetMs ?? DEFAULT_STABLE_RESET_MS + this.#stopTimeoutMs = options.stopTimeoutMs ?? 2000 } get state(): NativePetSupervisorState { - return this.#state + return copyEventSnapshot(this.#state) } + get snapshot() { return copyEventSnapshot({ revision: this.#revision, state: this.#state, generation: this.#generation?.identity ?? null, retiring: [...this.#retiring].map(generation => generation.identity) }) } + reloadConfig(): boolean { const generation = this.#generation - if (!generation?.client.ready || this.#state.status !== 'ready') + if (this.#stopping || this.#disposing || !generation?.client.ready || this.#state.status !== 'ready') return false generation.client.reloadConfig() + this.#publish({ kind: 'configuration', status: 'reload-requested' }, generation.identity) return true } start(): void { + if (this.#disposing) + throw new NativePetUnavailableError('PET_STOPPED') + if (this.#stopPromise) + throw new NativePetUnavailableError('PET_STOPPING') + if (this.#retiring.size) + throw new NativePetUnavailableError('PET_STOP_UNCONFIRMED') if (this.#generation || this.#restartTimer || this.#state.status === 'ready') return this.#stopping = false @@ -133,8 +158,19 @@ export class NativePetSupervisor { this.#startGeneration() } - async stop(): Promise { + stop(): Promise { + if (this.#stopPromise) + return this.#stopPromise this.#stopping = true + const stop = Promise.resolve().then(() => this.#stop()).finally(() => { + if (this.#stopPromise === stop) + this.#stopPromise = undefined + }) + this.#stopPromise = stop + return stop + } + + async #stop(): Promise { if (this.#restartTimer) { clearTimeout(this.#restartTimer) this.#restartTimer = null @@ -142,18 +178,41 @@ export class NativePetSupervisor { this.#clearStableTimer() const generation = this.#generation this.#generation = null + if (generation) { + this.#retiring.add(generation) + this.#setState({ status: 'stopping' }, generation.identity) + this.#publish({ kind: 'process', status: 'stop-requested' }, generation.identity) + } generation?.client.close(new NativePetUnavailableError()) generation?.process.stdin.end() generation?.process.kill('SIGTERM') + await this.#admissionTail + await this.#activeSequence?.promise.catch(() => {}) this.#activeSequence = null - this.#state = { status: 'stopped' } + const retiring = [...this.#retiring] + if (retiring.length) { + let timer: ReturnType | undefined + try { + const exited = await Promise.race([ + Promise.all(retiring.map(current => current.exited)).then(() => true), + new Promise(resolve => timer = setTimeout(resolve, this.#stopTimeoutMs, false)), + ]) + if (!exited) { + this.#setState({ status: 'stop-unknown' }, generation?.identity) + this.#publish({ kind: 'process', status: 'stop-unknown' }, generation?.identity) + throw new NativePetUnavailableError('PET_STOP_UNCONFIRMED') + } + } + finally { clearTimeout(timer) } + } + this.#setState({ status: 'stopped' }, generation?.identity) } async executeSequence(input: PetExecuteSequenceParams): Promise { const request = petExecuteSequenceParamsSchema.parse(input) const admitted = await this.#withAdmission(async () => { const generation = this.#generation - if (!generation || !generation.client.ready || this.#state.status !== 'ready') + if (this.#stopping || !generation || !generation.client.ready || this.#state.status !== 'ready') return failedResult('PET_UNAVAILABLE', 0) const previous = this.#activeSequence @@ -171,18 +230,20 @@ export class NativePetSupervisor { await previous.promise.catch(() => {}) } if ( - this.#generation?.id !== generation.id + this.#stopping + || this.#generation?.id !== generation.id || !generation.client.ready || this.#state.status !== 'ready' ) { return failedResult('PET_UNAVAILABLE', 0) } const active: ActiveSequence = { + operationId: randomUUID(), currentStepId: null, priority: request.priority, promise: Promise.resolve(failedResult('PET_UNAVAILABLE', 0)), } - active.promise = this.#runSequence(generation, request.steps, active) + active.promise = Promise.resolve().then(() => this.#runSequence(generation, request.steps, active)) this.#activeSequence = active return active }) @@ -209,7 +270,11 @@ export class NativePetSupervisor { const diagnostic = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error' - this.#diagnosticOutput.write(`Native pet process failed to start: ${diagnostic}\n`) + try { + this.#diagnosticOutput.write(`Native pet process failed to start: ${diagnostic}\n`) + } + catch {} + this.#publish({ kind: 'process', status: 'spawn-failed' }) this.#scheduleRestart() return } @@ -218,13 +283,14 @@ export class NativePetSupervisor { readable: petProcess.stdout, writable: petProcess.stdin, }) - const generation = { client, id, process: petProcess } + let resolveExit!: () => void + const generation = { client, id, process: petProcess, identity: randomUUID(), exited: new Promise(resolve => resolveExit = resolve), resolveExit: () => resolveExit() } this.#generation = generation - this.#state = { + this.#setState({ pid: petProcess.pid, restartAttempt: this.#restartAttempt, status: 'starting', - } + }) if (this.#captureStderr) this.#captureStderr(petProcess.stderr) else @@ -235,18 +301,20 @@ export class NativePetSupervisor { petProcess.kill('SIGTERM') }) petProcess.once('error', () => { - if (this.#generation?.id === id) + if (petProcess.pid === undefined) + this.#finishGeneration(generation, 'spawn-failed') + else if (this.#generation?.id === id) petProcess.kill('SIGTERM') }) - petProcess.once('exit', () => this.#handleExit(id)) + petProcess.once('exit', () => this.#finishGeneration(generation, 'exited')) void client.waitUntilReady(this.#readinessTimeoutMs).then(() => { if (this.#generation?.id !== id || this.#stopping) return - this.#state = { + this.#setState({ pid: petProcess.pid, restartAttempt: this.#restartAttempt, status: 'ready', - } + }) this.#scheduleStableReset(id) }).catch(() => { if (this.#generation?.id === id) @@ -254,6 +322,15 @@ export class NativePetSupervisor { }) } + #finishGeneration(generation: NativePetGeneration, status: 'exited' | 'spawn-failed'): void { + generation.resolveExit() + this.#retiring.delete(generation) + this.#publish({ kind: 'process', status }, generation.identity) + this.#handleExit(generation.id) + if (this.#stopping && !this.#generation && !this.#retiring.size) + this.#setState({ status: 'stopped' }, generation.identity) + } + #handleExit(id: number): void { if (this.#generation?.id !== id) return @@ -261,7 +338,7 @@ export class NativePetSupervisor { this.#generation = null this.#clearStableTimer() if (this.#stopping) { - this.#state = { status: 'stopped' } + this.#setState({ status: 'stopped' }) return } this.#scheduleRestart() @@ -272,10 +349,10 @@ export class NativePetSupervisor { return const delay = this.#restartDelaysMs[this.#restartAttempt] if (delay === undefined) { - this.#state = { restartAttempt: this.#restartAttempt, status: 'offline' } + this.#setState({ restartAttempt: this.#restartAttempt, status: 'offline' }) return } - this.#state = { restartAttempt: this.#restartAttempt, status: 'restarting' } + this.#setState({ restartAttempt: this.#restartAttempt, status: 'restarting' }) this.#restartAttempt += 1 this.#restartTimer = setTimeout(() => { this.#restartTimer = null @@ -290,7 +367,7 @@ export class NativePetSupervisor { if (this.#generation?.id !== id || this.#state.status !== 'ready') return this.#restartAttempt = 0 - this.#state = { ...this.#state, restartAttempt: 0 } + this.#setState({ ...this.#state, restartAttempt: 0 }) }, this.#stableResetMs) } @@ -327,8 +404,11 @@ export class NativePetSupervisor { return failedResult('PET_UNAVAILABLE', completedSteps) const stepId = `step_${randomUUID()}` active.currentStepId = stepId + const identity = { operationId: active.operationId, stepId, index: completedSteps, action: step.kind } + this.#publish({ kind: 'step', ...identity, status: 'dispatched', completedSteps }, generation.identity) try { const result = await generation.client.executeStep(step, stepId) + this.#publish({ kind: 'step', ...identity, status: result.status, completedSteps: completedSteps + (result.status === 'completed' ? 1 : 0) }, generation.identity) if (result.status === 'interrupted') { return { completedSteps, @@ -337,7 +417,7 @@ export class NativePetSupervisor { } } if (result.status === 'failed') { - await this.#recoverAfterFailure(generation) + await this.#recoverAfterFailure(generation, active.operationId) return failedResult(result.code, completedSteps) } completedSteps += 1 @@ -346,7 +426,7 @@ export class NativePetSupervisor { const code = error instanceof NativePetProtocolError ? 'PET_PROTOCOL_ERROR' : 'PET_UNAVAILABLE' - await this.#recoverAfterFailure(generation) + this.#publish({ kind: 'step', ...identity, status: 'unknown', completedSteps }, generation.identity) return failedResult(code, completedSteps) } finally { @@ -356,13 +436,33 @@ export class NativePetSupervisor { return { completedSteps, status: 'completed' } } - async #recoverAfterFailure(generation: NativePetGeneration): Promise { + async #recoverAfterFailure(generation: NativePetGeneration, operationId: string): Promise { if (this.#generation?.id !== generation.id || !generation.client.ready) return try { - await generation.client.executeStep(safeHomeStep()) + this.#publish({ kind: 'recovery', operationId, status: 'requested' }, generation.identity) + const result = await generation.client.executeStep(safeHomeStep()) + this.#publish({ kind: 'recovery', operationId, status: result.status === 'completed' ? 'completed' : 'failed' }, generation.identity) + } + catch { + this.#publish({ kind: 'recovery', operationId, status: 'unknown' }, generation.identity) } - catch {} + } + + #setState(state: NativePetSupervisorState, generation?: string): void { + if (JSON.stringify(this.#state) === JSON.stringify(state)) + return + this.#state = state + this.#publish({ kind: 'state', state }, generation) + } + + dispose(): Promise { + this.#disposing ??= this.stop().finally(() => this.#changes.dispose()) + return this.#disposing + } + + #publish(fact: NativePetFact, generation = this.#generation?.identity ?? null): void { + this.#changes.fire(copyEventSnapshot({ ...fact, generation, revision: ++this.#revision })) } } diff --git a/apps/buddy/electron/main/pet/__tests__/NativePetSupervisor.spec.ts b/apps/buddy/electron/main/pet/__tests__/NativePetSupervisor.spec.ts index 02fc85c2..bd7dfeea 100644 --- a/apps/buddy/electron/main/pet/__tests__/NativePetSupervisor.spec.ts +++ b/apps/buddy/electron/main/pet/__tests__/NativePetSupervisor.spec.ts @@ -14,12 +14,13 @@ class FakePetProcess extends EventEmitter implements NativePetChildProcess { readonly stdin = new PassThrough() readonly stdout = new PassThrough() readonly stderr = new PassThrough() - readonly pid: number + readonly pid: number | undefined readonly killSignals: Array = [] readonly messages: Array> = [] + autoExit = true #inputBuffer = '' - constructor(pid: number) { + constructor(pid?: number) { super() this.pid = pid this.stdin.setEncoding('utf8') @@ -37,6 +38,8 @@ class FakePetProcess extends EventEmitter implements NativePetChildProcess { kill(signal?: NodeJS.Signals | number): boolean { this.killSignals.push(signal) + if (this.autoExit) + this.exit(0) return true } @@ -75,6 +78,76 @@ function createSupervisor(options: { } describe('nativePetSupervisor', () => { + it.each([false, true])('releases an asynchronously failed spawn without requiring exit (stopping: %s)', async (stopping) => { + const failed = new FakePetProcess() + failed.autoExit = false + const replacement = new FakePetProcess(101) + const processes = [failed, replacement] + const supervisor = new NativePetSupervisor({ diagnosticOutput: new PassThrough(), spawnPet: () => processes.shift()!, restartDelaysMs: [], stopTimeoutMs: 50 }) + const changes: Array<{ kind: string, status?: string }> = [] + supervisor.onDidChange(change => changes.push(change)) + try { + supervisor.start() + const stopped = stopping ? supervisor.stop() : undefined + if (stopped) { + await Promise.resolve() + expect(supervisor.snapshot.retiring).toHaveLength(1) + } + failed.emit('error', Object.assign(new Error('spawn failed'), { code: 'EACCES' })) + await expect(stopped ?? supervisor.stop()).resolves.toBeUndefined() + expect(supervisor.snapshot).toMatchObject({ state: { status: 'stopped' }, generation: null, retiring: [] }) + expect(changes.filter(change => change.kind === 'process').map(change => change.status)).toContain('spawn-failed') + expect(changes.some(change => change.status === 'exited' || change.status === 'stop-unknown')).toBe(false) + supervisor.start() + replacement.ready() + await vi.waitFor(() => expect(supervisor.state).toMatchObject({ status: 'ready', pid: 101 })) + } + finally { await supervisor.dispose() } + }) + + it('does not confirm a spawned process has stopped after a process-control error', async () => { + vi.useFakeTimers() + const { supervisor, processes } = createSupervisor() + const changes: Array<{ kind: string, status?: string, generation: string | null }> = [] + supervisor.onDidChange(change => changes.push(change)) + try { + supervisor.start() + processes[0]!.ready() + await vi.runAllTicks() + processes[0]!.autoExit = false + processes[0]!.emit('error', new Error('process control failed')) + const stopped = expect(supervisor.stop()).rejects.toThrow('PET_STOP_UNCONFIRMED') + await vi.advanceTimersByTimeAsync(2000) + await stopped + expect(supervisor.state.status).toBe('stop-unknown') + expect(supervisor.snapshot.retiring).toHaveLength(1) + expect(() => supervisor.start()).toThrow('PET_STOP_UNCONFIRMED') + processes[0]!.exit(0) + expect(supervisor.state.status).toBe('stopped') + expect(supervisor.snapshot.retiring).toEqual([]) + expect(changes.filter(change => change.kind === 'process').map(change => change.status)).toEqual(['stop-requested', 'stop-unknown', 'exited']) + } + finally { vi.useRealTimers() } + }) + + it('keeps a timed-out dispatched step unknown without sending a compensating action', async () => { + vi.useFakeTimers() + const { supervisor, processes } = createSupervisor() + const changes: Array<{ kind: string, status?: string }> = [] + supervisor.onDidChange(change => changes.push(change)) + try { + supervisor.start() + processes[0]!.ready() + await vi.runAllTicks() + const execution = supervisor.executeSequence(sequence('fixture', 1)) + await vi.advanceTimersByTimeAsync(2000) + await expect(execution).resolves.toMatchObject({ status: 'failed', code: 'PET_UNAVAILABLE' }) + expect(executeMessages(processes[0]!)).toHaveLength(1) + expect(changes.filter(change => change.kind === 'step').map(change => change.status)).toEqual(['dispatched', 'unknown']) + await supervisor.stop() + } + finally { vi.useRealTimers() } + }) it('reports a native pet process startup failure', () => { const diagnosticOutput = new PassThrough() const diagnostics: string[] = [] diff --git a/apps/buddy/electron/main/platform/desktopFeatures.ts b/apps/buddy/electron/main/platform/desktopFeatures.ts index a32d4c1c..89957dfc 100644 --- a/apps/buddy/electron/main/platform/desktopFeatures.ts +++ b/apps/buddy/electron/main/platform/desktopFeatures.ts @@ -1,3 +1,4 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { BuddyFeatureId, BuddyPlatform } from '../../../shared/platform' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { LexoraConfig } from '../../shared/desktopApi' @@ -20,6 +21,7 @@ export interface DesktopFeature { interface DesktopFeatureContext { appPath: string diagnostics: DesktopDiagnosticLogger + report?: ApplicationDiagnosticReporter isPackaged: boolean onOpenDesktop: () => void paths: BuddyRuntimePaths @@ -66,6 +68,10 @@ function createNativePetFeature(context: DesktopFeatureContext): DesktopFeature resourcesPath: context.resourcesPath, }), }) + const diagnostics = supervisor.onDidChange((change) => { + const status = change.kind === 'state' ? change.state.status : change.status + context.report?.({ event: `pet.${change.kind}.${status.replaceAll('-', '_')}`, component: 'desktop.pet', level: ['failed', 'unknown', 'spawn-failed', 'stop-unknown', 'offline'].includes(status) ? 'warn' : 'info', revision: change.revision, ...(change.generation ? { generation: change.generation } : {}), ...('operationId' in change ? { operationId: change.operationId } : {}), ...('completedSteps' in change && change.status !== 'unknown' ? { count: change.completedSteps } : {}) }) + }) async function reloadExistingPet(): Promise { try { return await reloadNativePetConfig(environment) @@ -86,6 +92,13 @@ function createNativePetFeature(context: DesktopFeatureContext): DesktopFeature if (!supervisor.reloadConfig() && !(await reloadExistingPet())) supervisor.start() }, - stop: () => supervisor.stop(), + async stop() { + try { + await supervisor.dispose() + } + finally { + diagnostics.dispose() + } + }, } } diff --git a/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts b/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts index e67a66f7..ee23146b 100644 --- a/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts +++ b/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts @@ -1,10 +1,17 @@ import type { Writable } from 'node:stream' import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic' +import type { RuntimeLifecycleChange, RuntimeLifecycleSnapshot } from '../../../shared/lifecycle/runtimeLifecycle' +import type { LifecycleComponent, ServiceLifecycleSnapshot } from '../../../shared/lifecycle/serviceLifecycle' import type { BuddyServiceSupervisorFailureCode } from '../../../shared/runtime/runtimeProtocol' import type { BuddyServicePeer } from './BuddyServicePeer' import type { BuddyServiceProcessHandle, BuddyServiceProcessInstance } from './buddyServiceProcess' +import { randomUUID } from 'node:crypto' import process from 'node:process' import { APPLICATION_DIAGNOSTIC_METHOD, applicationDiagnosticSchema } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { SERVICE_LIFECYCLE_METHOD, serviceLifecycleChangeSchema } from '../../../shared/lifecycle/serviceLifecycle' +import { lifecycleDiagnostic } from '../../../shared/observability/lifecycleDiagnostics' import { BUDDY_SERVICE_PROTOCOL_VERSION, buddyServiceFailureNotificationSchema, @@ -51,6 +58,7 @@ export interface BuddyServiceSupervisorOptions { interface ServiceGeneration extends BuddyServiceProcessHandle { disposeBinding: () => void id: number + producerInstanceId: string ready: boolean terminationRequested: boolean } @@ -79,6 +87,7 @@ export class BuddyServiceSupervisor { readonly #onDiagnostic: BuddyServiceSupervisorOptions['onDiagnostic'] #generationStartedAt = 0 #sourceId = 'runtime' + #producerInstanceId = randomUUID() readonly #forceKillTimeoutMs: number readonly #notificationListeners = new Set<(notification: BuddyServiceNotification) => void>() readonly #readinessTimeoutMs: number @@ -86,7 +95,12 @@ export class BuddyServiceSupervisor { readonly #shutdownTimeoutMs: number readonly #spawnService: BuddyServiceSupervisorOptions['spawnService'] readonly #stableResetMs: number - readonly #stateListeners = new Set<(state: BuddyServiceSupervisorState) => void>() + readonly #stateChanges = new Emitter(() => this.#record({ event: 'observer.failed', component: 'runtime.supervisor', level: 'warn' })) + readonly #lifecycleChanges = new Emitter(() => this.#record({ event: 'observer.failed', component: 'runtime.lifecycle', level: 'warn' })) + readonly onDidChangeLifecycle = this.#lifecycleChanges.event + #connection: LifecycleComponent | null = null + #services: ServiceLifecycleSnapshot | null = null + #lifecycleRevision = 0 readonly #exitedProcesses = new WeakSet() readonly #exitCodes = new WeakMap() #lastShutdownClean = true @@ -98,12 +112,12 @@ export class BuddyServiceSupervisor { #readinessTimer: ReturnType | null = null #restartTimer: ReturnType | null = null #stableTimer: ReturnType | null = null - #state: BuddyServiceSupervisorState = { + #state: BuddyServiceSupervisorState = Object.freeze({ lastError: null, pid: null, restartAttempt: 0, status: 'stopped', - } + }) constructor(options: BuddyServiceSupervisorOptions) { this.#bindPeer = options.bindPeer @@ -115,12 +129,34 @@ export class BuddyServiceSupervisor { this.#shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS this.#spawnService = options.spawnService this.#stableResetMs = options.stableResetMs ?? DEFAULT_STABLE_RESET_MS + let previousStatus = this.#state.status + let previousError = this.#state.lastError + this.onDidChangeLifecycle(({ snapshot, connection }) => { + if (connection) + this.#record(lifecycleDiagnostic(connection)) + if (snapshot.status !== previousStatus || snapshot.errorCode !== previousError) { + previousStatus = snapshot.status + previousError = this.#state.lastError + this.#record({ event: `runtime.${snapshot.status}`, level: snapshot.errorCode ? 'warn' : 'info', attempt: this.#state.restartAttempt, ...(snapshot.errorCode ? { errorCode: snapshot.errorCode } : {}) }) + } + }) } get state(): BuddyServiceSupervisorState { return this.#state } + get lifecycleState(): RuntimeLifecycleSnapshot { + return copyEventSnapshot({ + revision: this.#lifecycleRevision, + generation: this.#connection?.operationId ?? null, + status: this.#state.status, + errorCode: this.#state.lastError, + connection: this.#connection, + services: this.#services, + }) + } + start(): void { if (this.#isReplacementBlocked()) return @@ -147,8 +183,8 @@ export class BuddyServiceSupervisor { } onStateChange(listener: (state: BuddyServiceSupervisorState) => void): () => void { - this.#stateListeners.add(listener) - return () => this.#stateListeners.delete(listener) + const subscription = this.#stateChanges.event(listener) + return () => subscription.dispose() } onNotification(listener: (notification: BuddyServiceNotification) => void): () => void { @@ -239,8 +275,11 @@ export class BuddyServiceSupervisor { return const id = ++this.#generationId this.#sourceId = `runtime-${id}` + this.#producerInstanceId = randomUUID() this.#generationStartedAt = performance.now() - this.#record({ event: 'component.starting', level: 'info', component: 'runtime.connection', operationId: this.#sourceId }) + this.#services = null + this.#connection = copyEventSnapshot({ component: 'runtime.connection', kind: 'service', status: 'starting', operationId: this.#sourceId }) + this.#publishLifecycle(this.#connection) let handle: BuddyServiceProcessHandle | null = null const pendingFatalError: { value: Error | null } = { value: null } const onFatalError = (error: Error) => { @@ -262,6 +301,7 @@ export class BuddyServiceSupervisor { ...handle, disposeBinding: this.#bindPeer?.(handle.peer) ?? (() => {}), id, + producerInstanceId: this.#producerInstanceId, ready: false, terminationRequested: false, } @@ -282,6 +322,19 @@ export class BuddyServiceSupervisor { } if (this.#generation?.id !== id) return + if (method === SERVICE_LIFECYCLE_METHOD) { + const parsed = serviceLifecycleChangeSchema.safeParse(params) + if (!parsed.success || parsed.data.snapshot.components.some(component => !component.component.startsWith('runtime.') || component.component === 'runtime.connection')) { + this.#record({ event: 'runtime.lifecycle_invalid', level: 'warn' }, generation) + return + } + const snapshot = parsed.data.snapshot + if (this.#services && (snapshot.sourceId !== this.#services.sourceId || snapshot.revision <= this.#services.revision)) + return + this.#services = copyEventSnapshot(snapshot) + this.#publishLifecycle() + return + } if (method === 'runtime.failed') { const failure = buddyServiceFailureNotificationSchema.safeParse(params) if (!failure.success) { @@ -506,28 +559,26 @@ export class BuddyServiceSupervisor { } #setState(state: BuddyServiceSupervisorState): void { - if (state.status !== this.#state.status || state.lastError !== this.#state.lastError) { - this.#record({ - event: `runtime.${state.status}`, - level: state.lastError ? 'warn' : 'info', - attempt: state.restartAttempt, - ...(state.lastError ? { errorCode: state.lastError } : {}), - }) - } this.#state = Object.freeze({ ...state }) - for (const listener of this.#stateListeners) - listener(this.#state) + this.#publishLifecycle() + this.#stateChanges.fire(this.#state) + } + + #publishLifecycle(connection?: LifecycleComponent): void { + this.#lifecycleRevision += 1 + this.#lifecycleChanges.fire(copyEventSnapshot({ snapshot: this.lifecycleState, ...(connection ? { connection } : {}) })) } #record(event: ApplicationDiagnostic, generation = this.#generation): void { try { - this.#onDiagnostic?.({ ...event, sourceId: generation ? `runtime-${generation.id}` : this.#sourceId, sourcePid: generation?.process.pid }) + this.#onDiagnostic?.({ ...event, producerInstanceId: generation?.producerInstanceId ?? this.#producerInstanceId, sourceId: generation ? `runtime-${generation.id}` : this.#sourceId, sourcePid: generation?.process.pid }) } catch {} } #connectionSettled(status: 'completed' | 'failed', errorCode?: string): void { - this.#record({ event: status === 'completed' ? 'component.ready' : 'component.start_failed', level: status === 'failed' ? 'error' : 'info', component: 'runtime.connection', operationId: this.#sourceId, durationMs: Math.round(performance.now() - this.#generationStartedAt), ...(errorCode ? { errorCode } : {}) }) + this.#connection = copyEventSnapshot({ component: 'runtime.connection', kind: 'service', status: status === 'completed' ? 'ready' : 'start_failed', operationId: this.#sourceId, durationMs: Math.round(performance.now() - this.#generationStartedAt), ...(errorCode ? { failure: { errorCode } } : {}) }) + this.#publishLifecycle(this.#connection) } #isReplacementBlocked(): boolean { diff --git a/apps/buddy/electron/main/runtime/__tests__/BuddyServiceSupervisor.spec.ts b/apps/buddy/electron/main/runtime/__tests__/BuddyServiceSupervisor.spec.ts index 4f7c7187..d59e4855 100644 --- a/apps/buddy/electron/main/runtime/__tests__/BuddyServiceSupervisor.spec.ts +++ b/apps/buddy/electron/main/runtime/__tests__/BuddyServiceSupervisor.spec.ts @@ -1,12 +1,24 @@ +import type { BuddyRunEvent } from '../../../../service/src/events/BuddyRunEvent' import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic' +import type { ApplicationLogRecord } from '../../../../shared/diagnostics/applicationLog' import type { BuddyServiceMessageProcess } from '../BuddyServicePeer' import type { BuddyServiceProcessInstance } from '../buddyServiceProcess' import type { BuddyServiceSupervisorOptions } from '../BuddyServiceSupervisor' import { EventEmitter } from 'node:events' import { Writable } from 'node:stream' +import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' +import { strFromU8, unzipSync } from 'fflate' import { describe, expect, it, vi } from 'vitest' +import { observeRunDiagnostics } from '../../../../service/src/events/observeRunDiagnostics' import { APPLICATION_DIAGNOSTIC_METHOD } from '../../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../../shared/events/Emitter' +import { SERVICE_LIFECYCLE_METHOD } from '../../../../shared/lifecycle/serviceLifecycle' +import { ServiceLifecycleSource } from '../../../../shared/lifecycle/ServiceLifecycleSource' +import { ApplicationEvents } from '../../../../shared/observability/ApplicationEvents' import { BUDDY_SERVICE_PROTOCOL_VERSION } from '../../../../shared/runtime/runtimeProtocol' +import { DesktopDiagnosticLogger } from '../../desktopDiagnostics' +import { createApplicationDiagnosticBundle } from '../../diagnostics/applicationDiagnosticBundle' +import { ApplicationLogReader } from '../../diagnostics/ApplicationLogReader' import { BuddyServicePeer } from '../BuddyServicePeer' import { BuddyServiceSupervisor } from '../BuddyServiceSupervisor' @@ -45,7 +57,7 @@ class FakeUtilityProcess extends EventEmitter implements BuddyServiceProcessInst function createSupervisor( restartDelaysMs: number[] = [5], - readiness: Pick = { readinessTimeoutMs: 100 }, + readiness: Pick = { readinessTimeoutMs: 100 }, ) { const processes: FakeUtilityProcess[] = [] const diagnostics: ApplicationDiagnostic[] = [] @@ -74,6 +86,75 @@ function createSupervisor( } describe('buddyServiceSupervisor utility process lifecycle', () => { + it('binds trusted producer identities across runtime generations and retains durable cursors through log export', async () => { + const directory = await createTemporaryDirectory('lexora-runtime-producer-diagnostics-') + const logger = new DesktopDiagnosticLogger({ directory, appVersion: '0.9.2', userHome: '/fixture' }) + const forgedProducer = crypto.randomUUID() + const { supervisor, processes } = createSupervisor([], { onDiagnostic: event => logger.record({ ...event, scope: 'local-service' }) }) + for (const [index, revision] of [41, 73].entries()) { + supervisor.start() + const process = processes[index]! + process.notify('runtime.ready', { protocolVersion: BUDDY_SERVICE_PROTOCOL_VERSION }) + const events = new ApplicationEvents({ producerInstanceId: forgedProducer }) + const commits = new Emitter(() => {}) + events.subscribe(event => process.notify(APPLICATION_DIAGNOSTIC_METHOD, event)) + const diagnostics = observeRunDiagnostics({ onDidCommit: commits.event }, { findById: () => null }, events.publish) + commits.fire({ runId: `run-${index}`, type: 'run.failed', sequence: revision, createdAt: new Date().toISOString(), payload: {} }) + const stopping = supervisor.stop() + await new Promise(resolve => setImmediate(resolve)) + events.publish({ event: 'service.stopped', level: 'info' }) + process.exit() + await stopping + diagnostics.dispose() + commits.dispose() + } + expect(await logger.close()).toMatchObject({ closeTimedOut: false, failed: 0, unconfirmed: 0 }) + const reader = new ApplicationLogReader(directory, logger.launchId, '/fixture') + const queried = await reader.query({ pageSize: 100 }) + const failures = queried.records.filter(record => record.event === 'run.failed').sort((a, b) => a.sequence - b.sequence) + expect(failures).toMatchObject([ + { runId: 'run-0', sourceSequence: 1, revision: 41 }, + { runId: 'run-1', sourceSequence: 1, revision: 73 }, + ]) + expect(new Set(failures.map(record => record.producerInstanceId)).size).toBe(2) + expect(failures.every(record => record.producerInstanceId && record.producerInstanceId !== forgedProducer)).toBe(true) + for (const record of failures) { + expect(queried.records).toContainEqual(expect.objectContaining({ event: 'service.stopped', producerInstanceId: record.producerInstanceId, sourceSequence: 2 })) + } + const bundle = await createApplicationDiagnosticBundle(reader, 'current') + const exported = strFromU8(unzipSync(bundle!.bytes)['context.jsonl']!).trim().split('\n').map(line => JSON.parse(line) as ApplicationLogRecord) + expect(exported.filter(record => record.event === 'run.failed')).toMatchObject(failures.map(({ producerInstanceId, runId, sourceSequence, revision }) => ({ producerInstanceId, runId, sourceSequence, revision }))) + expect(exported.some(record => 'sourceId' in record || record.producerInstanceId === forgedProducer)).toBe(false) + }) + + it('keeps typed lifecycle snapshots independent of failed diagnostics and retires old generation state', async () => { + const { processes, supervisor } = createSupervisor([], { onDiagnostic: () => { + throw new Error('diagnostics unavailable') + } }) + supervisor.onStateChange(() => { + throw new Error('optional observer failed') + }) + supervisor.start() + const source = new ServiceLifecycleSource() + source.reader.onDidChange(change => processes[0]!.notify(SERVICE_LIFECYCLE_METHOD, change)) + source.update({ component: 'runtime.database', kind: 'service', operationId: 'database-1', status: 'ready' }) + expect(supervisor.lifecycleState.services?.components[0]?.status).toBe('ready') + processes[0]!.notify('runtime.ready', { protocolVersion: BUDDY_SERVICE_PROTOCOL_VERSION }) + expect(supervisor.lifecycleState).toMatchObject({ generation: 'runtime-1', status: 'ready' }) + const stopping = supervisor.stop() + await new Promise(resolve => setImmediate(resolve)) + source.update({ component: 'runtime.database', kind: 'service', operationId: 'database-1', status: 'stopped' }) + expect(supervisor.lifecycleState.services?.components[0]?.status).toBe('ready') + processes[0]!.exit() + await stopping + expect(supervisor.lifecycleState.status).toBe('stopped') + supervisor.start() + expect(supervisor.lifecycleState).toMatchObject({ generation: 'runtime-3', services: null, status: 'starting' }) + const stopped = supervisor.stop() + await new Promise(resolve => setImmediate(resolve)) + processes[1]!.exit() + await stopped + }) it('keeps slow initialization and queued requests alive within the cold-start budget', async () => { vi.useFakeTimers() try { diff --git a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts index aec0e30b..a985485c 100644 --- a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts +++ b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts @@ -1,10 +1,12 @@ -import { EventEmitter } from 'node:events' +import type { CapturedDiagnosticOutput } from '../../diagnostics/diagnosticOutput' +import { EventEmitter, once } from 'node:events' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { PassThrough } from 'node:stream' import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' import { describe, expect, it } from 'vitest' import { DesktopDiagnosticLogger } from '../../desktopDiagnostics' +import { captureDiagnosticOutput } from '../../diagnostics/diagnosticOutput' import { forkBuddyServiceProcess, @@ -22,10 +24,19 @@ class FakeUtilityProcess extends EventEmitter { postMessage(message: unknown): void { this.sent.push(message) } + + exit(code: number): void { + try { + this.emit('exit', code) + } + finally { + this.stderr.removeAllListeners() + } + } } describe('buddyServiceProcess', () => { - it('records stderr across chunks and drains trailing output after process exit', async () => { + it('records stderr across chunks when the utility stream reaches EOF', async () => { const directory = await createTemporaryDirectory('lexora-service-stderr-') const logger = new DesktopDiagnosticLogger({ directory, userHome: '/home/alice', appVersion: '0.3.0' }) const process = new FakeUtilityProcess() @@ -35,14 +46,56 @@ describe('buddyServiceProcess', () => { mainModuleUrl: 'file:///workspace/buddy/index.js', }) process.stderr.write('Authorization: Bea') - process.emit('exit', 7) - const closing = logger.close() + const ended = once(process.stderr, 'end') process.stderr.end('rer fixture-secret') - expect(await closing).toMatchObject({ written: 1, closeTimedOut: false }) + await ended + process.exit(7) + expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false }) const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8')) expect(record).toMatchObject({ scope: 'local-service', message: 'Authorization: ' }) }) + it('finishes the capture and persists the terminal records when Electron removes stderr listeners at exit', async () => { + const directory = await createTemporaryDirectory('lexora-service-stderr-exit-') + const logger = new DesktopDiagnosticLogger({ directory, userHome: '/home/alice', appVersion: '0.3.0' }) + const process = new FakeUtilityProcess() + let capture: CapturedDiagnosticOutput | undefined + forkBuddyServiceProcess({ + captureStderr: (output) => { + capture = captureDiagnosticOutput(output, logger.createWritable('local-service'), error => logger.record({ scope: 'local-service', level: 'warn', event: 'process.stderr_failed', error })) + }, + forkProcess: () => process, + mainModuleUrl: 'file:///workspace/buddy/index.js', + }) + process.stderr.write('runtime cleanup ') + process.stderr.write('completed') + process.exit(0) + await capture!.done + logger.record({ scope: 'desktop', level: 'info', event: 'app.stopped' }) + expect(await logger.close()).toMatchObject({ written: 2, failed: 0, unconfirmed: 0, closeTimedOut: false }) + const records = (await readFile(join(directory, 'application.jsonl'), 'utf8')).trimEnd().split('\n').map(line => JSON.parse(line)) + expect(records).toMatchObject([ + { scope: 'local-service', event: 'process.stderr', message: 'runtime cleanup completed' }, + { scope: 'desktop', event: 'app.stopped' }, + ]) + }) + + it('propagates a stderr read error and closes the capture before utility exit', async () => { + const directory = await createTemporaryDirectory('lexora-service-stderr-error-') + const logger = new DesktopDiagnosticLogger({ directory, userHome: '/home/alice', appVersion: '0.3.0' }) + const process = new FakeUtilityProcess() + forkBuddyServiceProcess({ + captureStderr: output => logger.captureOutput('local-service', output), + forkProcess: () => process, + mainModuleUrl: 'file:///workspace/buddy/index.js', + }) + process.stderr.destroy(new Error('stderr read failed')) + expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false }) + const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8')) + expect(record).toMatchObject({ event: 'process.stderr_failed', error: { message: 'stderr read failed' } }) + process.exit(1) + }) + it('closes pending RPC when the utility process exits', async () => { const process = new FakeUtilityProcess() const handle = forkBuddyServiceProcess({ @@ -51,7 +104,7 @@ describe('buddyServiceProcess', () => { }) const response = handle.peer.request('runtime.status', {}) - process.emit('exit', 7) + process.exit(7) await expect(response).rejects.toThrow('exited with code 7') }) diff --git a/apps/buddy/electron/main/runtime/buddyServiceProcess.ts b/apps/buddy/electron/main/runtime/buddyServiceProcess.ts index db715d1f..a0a7d148 100644 --- a/apps/buddy/electron/main/runtime/buddyServiceProcess.ts +++ b/apps/buddy/electron/main/runtime/buddyServiceProcess.ts @@ -1,5 +1,6 @@ import type { BuddyServiceMessageProcess } from './BuddyServicePeer' import nodeProcess from 'node:process' +import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { utilityProcess } from 'electron' @@ -60,10 +61,30 @@ export function forkBuddyServiceProcess( }, ) if (process.stderr) { - if (options.captureStderr) - options.captureStderr(process.stderr) - else + if (options.captureStderr) { + const source = process.stderr + const output = new PassThrough() + function finish() { + source.unpipe(output) + source.removeListener('close', finish) + source.removeListener('error', fail) + output.end() + } + function fail(error: Error) { + finish() + output.destroy(error) + } + options.captureStderr(output) + source.once('close', finish) + source.once('error', fail) + source.pipe(output) + process.once('exit', finish) + if (!source.readable) + finish() + } + else { process.stderr.pipe(nodeProcess.stderr, { end: false }) + } } const peer = new BuddyServicePeer({ onFatalError: options.onFatalError, diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts index 7993444d..78db7f9d 100644 --- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts +++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts @@ -8,6 +8,7 @@ import { createSandboxDirectory } from '../../../platform/process/sandboxDirecto import { createSandboxEnvironment } from '../../../platform/process/sandboxEnvironment' import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath' +import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle' import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema } from '../../../shared/permissions/shellSandbox' import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' import { BuddyServicePeer } from '../runtime/BuddyServicePeer' @@ -82,6 +83,12 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa })) child.once('error', () => processPeer.close(new Error('Sandbox supervisor failed'))) processPeer.onNotification((method, params) => { + if (method === 'sandbox.lifecycle' && !disposed) { + const event = sandboxLifecycleNotificationSchema.safeParse(params) + if (event.success && event.data.requestId === input.requestId) + peer.notify('host.sandbox.lifecycle', event.data) + return + } if (method !== 'sandbox.output' || disposed || controller.signal.aborted) return const output = sandboxOutputSchema.parse(params) diff --git a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts index 5c338cad..58b2b393 100644 --- a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts +++ b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts @@ -107,7 +107,7 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr await absent(join(workspace, 'late')) } finally { - client.dispose() + await client.dispose() disposeHost() host.close(new Error('Installation verification completed')) runtime.close(new Error('Installation verification completed')) diff --git a/apps/buddy/electron/main/workbench/WorkbenchStateStore.ts b/apps/buddy/electron/main/workbench/WorkbenchStateStore.ts index a34fd461..66fa23a1 100644 --- a/apps/buddy/electron/main/workbench/WorkbenchStateStore.ts +++ b/apps/buddy/electron/main/workbench/WorkbenchStateStore.ts @@ -47,7 +47,7 @@ export class WorkbenchStateStore { } } - write(state: WorkbenchState): Promise { + write(state: WorkbenchState, resetRecovery = false): Promise { const parsed = workbenchStateSchema.parse(state) const data = JSON.stringify(parsed) if (Buffer.byteLength(data) > maximumBytes) @@ -68,7 +68,7 @@ export class WorkbenchStateStore { } try { // The previous snapshot remains independently readable after a failed replace. - const previous = await readFile(path) + const previous = resetRecovery ? Buffer.from(data) : await readFile(path) const previousPath = join(this.#directory, `workbench-previous-${randomUUID()}.tmp`) const backup = await open(previousPath, 'wx', 0o600) try { diff --git a/apps/buddy/electron/main/workbench/__tests__/WorkbenchStateStore.spec.ts b/apps/buddy/electron/main/workbench/__tests__/WorkbenchStateStore.spec.ts index 04d28dbe..4215b578 100644 --- a/apps/buddy/electron/main/workbench/__tests__/WorkbenchStateStore.spec.ts +++ b/apps/buddy/electron/main/workbench/__tests__/WorkbenchStateStore.spec.ts @@ -11,6 +11,19 @@ afterEach(async () => { vi.restoreAllMocks() await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) }) +it('checkpoints explicit cleanup into both snapshots without losing unrelated working copies', async () => { + const directory = await mkdtemp(join(tmpdir(), 'lexora-workbench-')) + directories.push(directory) + const store = new WorkbenchStateStore(directory) + const snapshot: WorkbenchState = { version: 1, layout: { plugin: { private: 'old' }, task: 'retained' }, configuration: { theme: 'dark' }, backups: [{ key: 'file:fixture', resource: {}, text: 'unsaved user content', baseText: '', etag: 'hash', savedAt: '2026-09-28' }] } + await store.read() + await store.write(snapshot) + const clean = { ...snapshot, layout: { task: 'retained' } } + await store.write(clean, true) + expect(JSON.parse(await readFile(join(directory, 'workbench.previous.json'), 'utf8'))).toEqual(clean) + await writeFile(join(directory, 'workbench.json'), '{broken') + expect(await new WorkbenchStateStore(directory).read()).toEqual(clean) +}) it('preserves saved content after a failed replacement and permits a later retry', async () => { const directory = await mkdtemp(join(tmpdir(), 'lexora-workbench-')) directories.push(directory) diff --git a/apps/buddy/electron/main/workbench/registerWorkbenchIpc.ts b/apps/buddy/electron/main/workbench/registerWorkbenchIpc.ts index e13ad621..0a655633 100644 --- a/apps/buddy/electron/main/workbench/registerWorkbenchIpc.ts +++ b/apps/buddy/electron/main/workbench/registerWorkbenchIpc.ts @@ -1,7 +1,7 @@ import type { BrowserWindow } from 'electron' import type { WorkbenchStateStore } from './WorkbenchStateStore' import { ipcMain } from 'electron' -import { workbenchStateSchema } from '../../../shared/workbench/workbenchState' +import { workbenchStateSchema, workbenchWriteOptionsSchema } from '../../../shared/workbench/workbenchState' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { assertTrustedSender } from '../ipc' @@ -10,9 +10,9 @@ export function registerWorkbenchIpc(store: WorkbenchStateStore, getWindow: () = assertTrustedSender(event, getWindow()) return store.read() }) - ipcMain.handle(DESKTOP_IPC_CHANNELS.workbenchWrite, (event, input: unknown) => { + ipcMain.handle(DESKTOP_IPC_CHANNELS.workbenchWrite, (event, input: unknown, options: unknown) => { assertTrustedSender(event, getWindow()) - return store.write(workbenchStateSchema.parse(input)) + return store.write(workbenchStateSchema.parse(input), workbenchWriteOptionsSchema.parse(options ?? {}).resetRecovery) }) return () => { ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.workbenchRead) diff --git a/apps/buddy/electron/preload/desktop.ts b/apps/buddy/electron/preload/desktop.ts index e0b33ce6..f3eaefb4 100644 --- a/apps/buddy/electron/preload/desktop.ts +++ b/apps/buddy/electron/preload/desktop.ts @@ -1,7 +1,8 @@ import type { ContextPanelCommand, ContextPanelState } from '../../shared/context-panel/contextPanel' -import type { ApplicationDiagnostic } from '../../shared/diagnostics/applicationDiagnostic' import type { ApplicationLogExport, ApplicationLogQuery } from '../../shared/diagnostics/applicationLog' import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup' +import type { RendererDiagnosticReport } from '../../shared/diagnostics/rendererDiagnostic' +import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' import type { DesktopAppInfo, DesktopOpenTarget, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi' import type { DesktopCommandId } from '../shared/desktopCommands' import { ipcRenderer, webUtils } from 'electron' @@ -12,7 +13,7 @@ export function createDesktopApi(): Pick ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.workbenchRead), - write: (state: import('../../shared/workbench/workbenchState').WorkbenchState) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.workbenchWrite, state), + write: (state: import('../../shared/workbench/workbenchState').WorkbenchState, options?: import('../../shared/workbench/workbenchState').WorkbenchWriteOptions) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.workbenchWrite, state, options), }), contextPanel: Object.freeze({ getState: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.contextPanelGetState), @@ -25,6 +26,7 @@ export function createDesktopApi(): Pick ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsReport, input), exportDiagnostics: (input: ApplicationLogExport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics, { launch: input.launch }), query: (input: ApplicationLogQuery) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsQuery, { ...input, @@ -34,7 +36,7 @@ export function createDesktopApi(): Pick ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appStartupGetState), onStateChanged: (listener: (state: ApplicationStartupState) => void) => subscribe(DESKTOP_IPC_CHANNELS.appStartupStateChanged, listener), - reportEvent: (event: ApplicationDiagnostic) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appStartupReport, { ...event }), + reportLifecycle: (report: RendererLifecycleReport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appStartupReport, report), }), checkForUpdates: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appCheckForUpdates), getInfo: (): Promise => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appGetInfo), @@ -79,6 +81,7 @@ export function createDesktopApi(): Pick ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.commandExecute, commandId), }), settings: Object.freeze({ + onChanged: listener => subscribe(DESKTOP_IPC_CHANNELS.settingsChanged, listener), get: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.settingsGet), update: (patch: LexoraConfigPatch) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.settingsUpdate, patch), }), diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index fd1a0708..9d3ccb10 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -7,6 +7,9 @@ export function createExtensionApi(): ExtensionApi { const request = (input: ExtensionManagementRequest) => ipcRenderer.invoke(EXTENSION_IPC.request, input) return Object.freeze({ list: () => request({ action: 'list' }), + configuration: id => request({ action: 'configuration', id }), + configurationSnapshot: id => request({ action: 'configurationSnapshot', id }), + configure: (id, patch) => request({ action: 'configure', id, patch }), installations: () => request({ action: 'installations' }), catalog: (refresh = false) => request({ action: 'catalog', refresh }), reviewCatalog: (id, version) => request({ action: 'reviewCatalog', id, version }), @@ -16,7 +19,7 @@ export function createExtensionApi(): ExtensionApi { cancelInstall: token => request({ action: 'cancelInstall', token }), enable: (id, enabled) => request({ action: 'enable', id, enabled }), restart: id => request({ action: 'restart', id }), - uninstall: id => request({ action: 'uninstall', id }), + uninstall: (id, options) => request({ action: 'uninstall', id, clearData: options?.clearData ?? false }), devtools: id => request({ action: 'devtools', id }), revokeResources: id => request({ action: 'revokeResources', id }), execute: (id, command, resource) => request({ action: 'execute', id, command, resource }), diff --git a/apps/buddy/electron/preload/local-chat/activity.ts b/apps/buddy/electron/preload/local-chat/activity.ts index a0525679..dc709938 100644 --- a/apps/buddy/electron/preload/local-chat/activity.ts +++ b/apps/buddy/electron/preload/local-chat/activity.ts @@ -1,16 +1,21 @@ +import type { ArtifactChangeNotice } from '../../../shared/artifacts/artifactApi' +import type { ChangeSetChangeNotice } from '../../../shared/changes/changeApi' import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' +import { subscribe } from '../subscribe' export function createActivityApi(): Pick { return { artifacts: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.artifactsChanged, listener), readText: artifactId => ipcRenderer.invoke( LOCAL_CHAT_IPC_CHANNELS.artifactsReadText, { artifactId }, ), }), notifications: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.notificationsChanged, listener), list: () => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.notificationsList), markAllSeen: () => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.notificationsMarkAllSeen), markSeen: (notificationId, revision) => ipcRenderer.invoke( @@ -19,6 +24,7 @@ export function createActivityApi(): Pick subscribe(LOCAL_CHAT_IPC_CHANNELS.changesChanged, listener), overview: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesOverview, { ...input }), get: changeSetId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesGet, { changeSetId }), }), diff --git a/apps/buddy/electron/preload/local-chat/composer.ts b/apps/buddy/electron/preload/local-chat/composer.ts index 1dc5f677..32ca221a 100644 --- a/apps/buddy/electron/preload/local-chat/composer.ts +++ b/apps/buddy/electron/preload/local-chat/composer.ts @@ -1,10 +1,12 @@ import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' +import { subscribe } from '../subscribe' export function createComposerApi(): Pick { return { composerResources: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.composerResourcesChanged, listener), accept: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.composerResourcesAccept, input), complete: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.composerResourcesComplete, input), fail: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.composerResourcesFail, input), diff --git a/apps/buddy/electron/preload/local-chat/connectors.ts b/apps/buddy/electron/preload/local-chat/connectors.ts index b4f1521c..8930ff74 100644 --- a/apps/buddy/electron/preload/local-chat/connectors.ts +++ b/apps/buddy/electron/preload/local-chat/connectors.ts @@ -1,10 +1,13 @@ +import type { ConnectorChangeNotice } from '../../../shared/connectors/connectorApi' import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' +import { subscribe } from '../subscribe' export function createConnectorsApi(): Pick { return { connectors: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.connectorsChanged, listener), setEnabled: (connectorId, enabled) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.connectorsSetEnabled, { connectorId, enabled }), test: connectorId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.connectorsTest, { connectorId }), tools: connectorId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.connectorsTools, { connectorId }), diff --git a/apps/buddy/electron/preload/local-chat/conversation.ts b/apps/buddy/electron/preload/local-chat/conversation.ts index 6c5bb9ed..8f504f77 100644 --- a/apps/buddy/electron/preload/local-chat/conversation.ts +++ b/apps/buddy/electron/preload/local-chat/conversation.ts @@ -15,6 +15,7 @@ export function createConversationApi(): Pick ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.workspaceStateWrite, { value }), }), conversations: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.conversationsChanged, listener), getNodeDetail: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetNodeDetail, input), getTree: conversationId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetTree, { conversationId }), list: limit => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsList, { limit }), diff --git a/apps/buddy/electron/preload/local-chat/providers.ts b/apps/buddy/electron/preload/local-chat/providers.ts index d3870157..85f439d0 100644 --- a/apps/buddy/electron/preload/local-chat/providers.ts +++ b/apps/buddy/electron/preload/local-chat/providers.ts @@ -1,4 +1,4 @@ -import type { LocalProviderAuthChallenge } from '../../../shared/providers/providerApi' +import type { LocalProviderAuthChallenge, ProviderChanged } from '../../../shared/providers/providerApi' import type { ProviderRequestHeader } from '../../../shared/providers/providerHeaders' import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' @@ -8,6 +8,7 @@ import { subscribe } from '../subscribe' export function createProvidersApi(): Pick { return { providers: Object.freeze({ + onChanged: (listener: (event: ProviderChanged) => void) => subscribe(LOCAL_CHAT_IPC_CHANNELS.providersChanged, listener), acknowledgeModelSourceUpdate: (providerId, modelId) => ipcRenderer.invoke( LOCAL_CHAT_IPC_CHANNELS.providersAcknowledgeModelSource, { modelId, providerId }, diff --git a/apps/buddy/electron/preload/local-chat/skills.ts b/apps/buddy/electron/preload/local-chat/skills.ts index 1926194a..d6e2ed11 100644 --- a/apps/buddy/electron/preload/local-chat/skills.ts +++ b/apps/buddy/electron/preload/local-chat/skills.ts @@ -1,3 +1,4 @@ +import type { SkillChangeNotice } from '../../../shared/skills/skillApi' import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' @@ -17,7 +18,7 @@ export function createSkillsApi(): Pick { remove: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsRemove, { ...input }), reveal: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsReveal, { ...input }), onChanged(listener) { - const handle = (_event: Electron.IpcRendererEvent, input: { spaceId: string | null }) => listener(input.spaceId) + const handle = (_event: Electron.IpcRendererEvent, input: SkillChangeNotice) => listener(Object.freeze({ ...input })) ipcRenderer.on(LOCAL_CHAT_IPC_CHANNELS.skillsChanged, handle) return () => ipcRenderer.removeListener(LOCAL_CHAT_IPC_CHANNELS.skillsChanged, handle) }, diff --git a/apps/buddy/electron/preload/local-chat/spaces.ts b/apps/buddy/electron/preload/local-chat/spaces.ts index e04c87a7..5353206e 100644 --- a/apps/buddy/electron/preload/local-chat/spaces.ts +++ b/apps/buddy/electron/preload/local-chat/spaces.ts @@ -1,10 +1,13 @@ +import type { SpaceChangeNotice } from '../../../shared/spaces/spaceApi' import type { LocalChatApi } from '../../shared/localChatApi' import { ipcRenderer } from 'electron' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' +import { subscribe } from '../subscribe' export function createSpacesApi(): Pick { return { spaces: Object.freeze({ + onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.spacesChanged, listener), readDocument: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentRead, { ...input }), saveDocument: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentSave, { ...input }), listDirectory: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesList, { ...input }), diff --git a/apps/buddy/electron/preload/local-chat/web.ts b/apps/buddy/electron/preload/local-chat/web.ts index 028508ca..44d6c7b1 100644 --- a/apps/buddy/electron/preload/local-chat/web.ts +++ b/apps/buddy/electron/preload/local-chat/web.ts @@ -5,6 +5,11 @@ import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' export function createWebApi(): Pick { return { web: Object.freeze({ + onChanged: (listener) => { + const handler = () => listener() + ipcRenderer.on(LOCAL_CHAT_IPC_CHANNELS.webSettingsChanged, handler) + return () => ipcRenderer.removeListener(LOCAL_CHAT_IPC_CHANNELS.webSettingsChanged, handler) + }, read: () => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.webSettingsRead), save: settings => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.webSettingsSave, settings), saveCredential: key => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.webCredentialSave, { key }), diff --git a/apps/buddy/electron/raw.d.ts b/apps/buddy/electron/raw.d.ts index 70549054..ed872400 100644 --- a/apps/buddy/electron/raw.d.ts +++ b/apps/buddy/electron/raw.d.ts @@ -2,3 +2,7 @@ declare module '*?raw' { const source: string export default source } +declare module 'virtual:extension-runtime/*' { + const source: string + export default source +} diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index 5e8e1792..84cc6d27 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -1,7 +1,8 @@ import type { DesktopBrowserApi } from '../../shared/browser/browserDesktopApi' -import type { ApplicationDiagnostic } from '../../shared/diagnostics/applicationDiagnostic' import type { ApplicationLogApi } from '../../shared/diagnostics/applicationLog' import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup' +import type { RendererDiagnosticApi } from '../../shared/diagnostics/rendererDiagnostic' +import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' import type { SandboxEnvironmentStatus, SandboxSetupResult } from '../../shared/permissions/shellSandbox' import type { BuddyCapabilities } from '../../shared/platform' import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' @@ -15,6 +16,7 @@ export const DESKTOP_IPC_CHANNELS = { contextPanelExecute: 'lexora:context-panel:execute', contextPanelStateChanged: 'lexora:context-panel:state-changed', appLogsQuery: 'lexora:app:logs:query', + appLogsReport: 'lexora:app:logs:report', appLogsExportDiagnostics: 'lexora:app:logs:export-diagnostics', appStartupGetState: 'lexora:app:startup:get-state', appStartupReport: 'lexora:app:startup:report', @@ -54,6 +56,7 @@ export const DESKTOP_IPC_CHANNELS = { commandExecute: 'lexora:command:execute', settingsGet: 'lexora:settings:get', settingsUpdate: 'lexora:settings:update', + settingsChanged: 'lexora:settings:changed', windowGetState: 'lexora:window:get-state', windowMinimize: 'lexora:window:minimize', windowStateChanged: 'lexora:window:state-changed', @@ -199,11 +202,11 @@ export interface LexoraDesktopApi { workbench: import('../../shared/workbench/workbenchState').WorkbenchStateApi contextPanel: import('../../shared/context-panel/contextPanel').ContextPanelApi app: { - logs: ApplicationLogApi + logs: ApplicationLogApi & RendererDiagnosticApi startup: { getState: () => Promise onStateChanged: (listener: (state: ApplicationStartupState) => void) => () => void - reportEvent: (event: ApplicationDiagnostic) => Promise + reportLifecycle: (report: RendererLifecycleReport) => Promise } checkForUpdates: () => Promise getInfo: () => Promise @@ -224,6 +227,7 @@ export interface LexoraDesktopApi { execute: (commandId: DesktopCommandId) => Promise } settings: { + onChanged: (listener: (config: LexoraConfig) => void) => () => void get: () => Promise update: (patch: LexoraConfigPatch) => Promise } diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index 4d88cc21..4c128566 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -1,12 +1,12 @@ import type { LocalConversationStatus } from '@buddy-shared/runs/conversationStatusApi' -import type { LocalArtifactText } from '../../shared/artifacts/artifactApi' +import type { ArtifactChangeNotice, LocalArtifactText } from '../../shared/artifacts/artifactApi' import type { LocalAutomation, LocalAutomationCreateRequest, LocalAutomationListRequest, LocalAutomationMutationRequest, LocalAutomationOccurrenceListRequest, LocalAutomationOccurrencePage, LocalAutomationPage, LocalAutomationPreviewRequest, LocalAutomationPreviewResult, LocalAutomationRunNowResult, LocalAutomationUpdateRequest } from '../../shared/automation/automationApi' -import type { ChangeOverviewRequest, LocalChangeOverview, LocalChangeSetDetail } from '../../shared/changes/changeApi' -import type { LocalConnector, LocalConnectorConfig, LocalConnectorCredential, LocalConnectorCredentialMutation } from '../../shared/connectors/connectorApi' +import type { ChangeOverviewRequest, ChangeSetChangeNotice, LocalChangeOverview, LocalChangeSetDetail } from '../../shared/changes/changeApi' +import type { ConnectorChangeNotice, LocalConnector, LocalConnectorConfig, LocalConnectorCredential, LocalConnectorCredentialMutation } from '../../shared/connectors/connectorApi' import type { ConnectorRuntimeState, ConnectorToolSummary } from '../../shared/connectors/connectorState' import type { LocalChatCommandRequest, LocalStartTurnRequest, LocalTurnStart } from '../../shared/conversation/chatApi' import type { LocalChatQueueItem, LocalChatQueueReceipt, LocalChatQueueScope, LocalChatQueueTarget } from '../../shared/conversation/chatQueueApi' -import type { LocalComposerDraft, LocalComposerDraftDiscard, LocalComposerDraftOpen, LocalComposerDraftSave } from '../../shared/conversation/composerApi' +import type { ComposerResourcesChanged, LocalComposerDraft, LocalComposerDraftDiscard, LocalComposerDraftOpen, LocalComposerDraftSave } from '../../shared/conversation/composerApi' import type { BuddyComposerResource, BuddyComposerResourceAccept, @@ -24,19 +24,19 @@ import type { LocalTaskMark, LocalTaskMarkState, TaskMarkClearInput, TaskMarkInp import type { LocalWorkspaceSetting, LocalWorkspaceStateValue } from '../../shared/conversation/workspaceApi' import type { DirectoryPage, FilePreview } from '../../shared/files/filePreview' import type { WebSettings, WebSettingsSnapshot } from '../../shared/network/webProtocol' -import type { LocalNotificationList } from '../../shared/notifications/notificationApi' +import type { LocalNotificationList, NotificationsChanged } from '../../shared/notifications/notificationApi' import type { LocalApproval } from '../../shared/permissions/approvalApi' import type { ApprovalGrantScope } from '../../shared/permissions/approvalReviewPayload' import type { BuddyPermissionSettings } from '../../shared/permissions/permissionMode' -import type { LocalBuiltinProviderPreset, LocalCustomProvider, LocalCustomProviderModel, LocalDefaultModel, LocalModelSnapshot, LocalProvider, LocalProviderAuthChallenge, LocalRuntimeModelOption } from '../../shared/providers/providerApi' +import type { LocalBuiltinProviderPreset, LocalCustomProvider, LocalCustomProviderModel, LocalDefaultModel, LocalModelSnapshot, LocalProvider, LocalProviderAuthChallenge, LocalRuntimeModelOption, ProviderChanged } from '../../shared/providers/providerApi' import type { ModelCapabilityOverrides } from '../../shared/providers/providerCapabilities' import type { ModelCatalogReference } from '../../shared/providers/providerCatalog' import type { ProviderRequestHeader } from '../../shared/providers/providerHeaders' import type { LocalRun, LocalRunEvent } from '../../shared/runs/runApi' import type { LocalBuddyServiceSupervisorState } from '../../shared/runtime/serviceState' -import type { LocalSkillCatalog, SkillDetail, SkillDirectoryRequest, SkillFileTarget, SkillInstallPreview, SkillPreviewInput } from '../../shared/skills/skillApi' +import type { LocalSkillCatalog, SkillChangeNotice, SkillDetail, SkillDirectoryRequest, SkillFileTarget, SkillInstallPreview, SkillPreviewInput } from '../../shared/skills/skillApi' -import type { LocalSpace, LocalSpaceCreateInput, LocalSpaceFile, LocalSpaceUpdateInput } from '../../shared/spaces/spaceApi' +import type { LocalSpace, LocalSpaceCreateInput, LocalSpaceFile, LocalSpaceUpdateInput, SpaceChangeNotice } from '../../shared/spaces/spaceApi' import type { LocalSpaceDirectoryPage, LocalSpaceFilePreview, SpaceDirectoryRequest, SpaceFileTarget } from '../../shared/spaces/spaceFileApi' import type { LocalUsageAnalytics, LocalUsageTopTasks, LocalUsageTrend, UsagePeriod, UsageTopTasksRequest, UsageTrendRequest } from '../../shared/usage/usageAnalyticsApi' @@ -56,12 +56,15 @@ export const LOCAL_CHAT_IPC_CHANNELS = { spaceFilesRead: 'lexora:buddy:space-files:read', spaceFilesReveal: 'lexora:buddy:space-files:reveal', webSettingsRead: 'lexora:buddy:web:settings', + webSettingsChanged: 'lexora:buddy:web:settings-changed', webSettingsSave: 'lexora:buddy:web:save-settings', webCredentialSave: 'lexora:buddy:web:save-credential', webCredentialReveal: 'lexora:buddy:web:reveal-credential', approvalsApprove: 'lexora:buddy:approvals:approve', approvalsDeny: 'lexora:buddy:approvals:deny', approvalsList: 'lexora:buddy:approvals:list', + artifactsChanged: 'lexora:buddy:artifacts:changed', + changesChanged: 'lexora:buddy:changes:changed', artifactsReadText: 'lexora:buddy:artifacts:read-text', automationChanged: 'lexora:buddy:automations:changed', automationsCreate: 'lexora:buddy:automations:create', @@ -75,6 +78,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { automationsResume: 'lexora:buddy:automations:resume', automationsRunNow: 'lexora:buddy:automations:run-now', automationsUpdate: 'lexora:buddy:automations:update', + composerResourcesChanged: 'lexora:buddy:composer-resources:changed', composerResourcesAccept: 'lexora:buddy:composer-resources:accept', composerResourcesComplete: 'lexora:buddy:composer-resources:complete', composerResourcesFail: 'lexora:buddy:composer-resources:fail', @@ -101,6 +105,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { chatStartTurn: 'lexora:buddy:chat:start-turn', changesGet: 'lexora:buddy:changes:get', contextUsageSnapshot: 'lexora:buddy:context:usage-snapshot', + connectorsChanged: 'lexora:buddy:connectors:changed', connectorsClearCredential: 'lexora:buddy:connectors:clear-credential', connectorsSetEnabled: 'lexora:buddy:connectors:set-enabled', connectorsTest: 'lexora:buddy:connectors:test', @@ -121,12 +126,15 @@ export const LOCAL_CHAT_IPC_CHANNELS = { conversationsListBranches: 'lexora:buddy:conversations:list-branches', conversationsListMessages: 'lexora:buddy:conversations:list-messages', conversationsRename: 'lexora:buddy:conversations:rename', + conversationsChanged: 'lexora:buddy:conversations:changed', conversationsSetPermissionSettings: 'lexora:buddy:conversations:set-permission-settings', conversationsSetModelSelection: 'lexora:buddy:conversations:set-model-selection', conversationsListTimeline: 'lexora:buddy:conversations:list-timeline', + notificationsChanged: 'lexora:buddy:notifications:changed', notificationsList: 'lexora:buddy:notifications:list', notificationsMarkAllSeen: 'lexora:buddy:notifications:mark-all-seen', notificationsMarkSeen: 'lexora:buddy:notifications:mark-seen', + spacesChanged: 'lexora:buddy:spaces:changed', spacesCreate: 'lexora:buddy:spaces:create', spaceDocumentRead: 'lexora:buddy:spaces:document:read', spaceDocumentSave: 'lexora:buddy:spaces:document:save', @@ -136,6 +144,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { spacesSelectDirectory: 'lexora:buddy:spaces:select-directory', spacesUpdate: 'lexora:buddy:spaces:update', providerAuthChallenge: 'lexora:buddy:providers:auth-challenge', + providersChanged: 'lexora:buddy:providers:changed', providersCancelAuth: 'lexora:buddy:providers:cancel-auth', providersAdd: 'lexora:buddy:providers:add', providersClearCredential: 'lexora:buddy:providers:clear-credential', @@ -217,6 +226,7 @@ export interface LocalChatApi { save: (input: LocalComposerDraftSave) => Promise } artifacts: { + onChanged: (listener: (event: ArtifactChangeNotice) => void) => () => void readText: (artifactId: string) => Promise } automations: { @@ -241,6 +251,7 @@ export interface LocalChatApi { onStateChanged: (listener: (state: LocalBuddyServiceSupervisorState) => void) => () => void } providers: { + onChanged: (listener: (event: ProviderChanged) => void) => () => void acknowledgeModelSourceUpdate: ( providerId: string, modelId: string, @@ -299,11 +310,13 @@ export interface LocalChatApi { onAuthChallenge: (listener: (challenge: LocalProviderAuthChallenge) => void) => () => void } notifications: { + onChanged: (listener: (event: NotificationsChanged) => void) => () => void list: () => Promise markAllSeen: () => Promise markSeen: (notificationId: string, revision: string) => Promise } spaces: { + onChanged: (listener: (event: SpaceChangeNotice) => void) => () => void readDocument: (input: SpaceFileTarget) => Promise saveDocument: (input: import('../../shared/spaces/spaceFileApi').SpaceSaveDocument) => Promise listDirectory: (input: SpaceDirectoryRequest) => Promise @@ -329,9 +342,10 @@ export interface LocalChatApi { setEnabled: (input: { spaceId: string | null, id: string, enabled: boolean, revision: string }) => Promise remove: (input: { spaceId: string | null, id: string, revision: string }) => Promise reveal: (input: { spaceId: string | null, id: string }) => Promise - onChanged: (listener: (spaceId: string | null) => void) => () => void + onChanged: (listener: (event: SkillChangeNotice) => void) => () => void } connectors: { + onChanged: (listener: (event: ConnectorChangeNotice) => void) => () => void setEnabled: (connectorId: string, enabled: boolean) => Promise test: (connectorId: string) => Promise tools: (connectorId: string) => Promise> @@ -356,6 +370,7 @@ export interface LocalChatApi { ) => Promise } web: { + onChanged: (listener: () => void) => () => void read: () => Promise save: (settings: WebSettings) => Promise saveCredential: (key: string | null) => Promise @@ -366,6 +381,7 @@ export interface LocalChatApi { write: (value: LocalWorkspaceStateValue) => Promise } conversations: { + onChanged: (listener: (conversation: LocalConversation) => void) => () => void getNodeDetail: (input: ConversationNodeDetailRequest) => Promise getTree: (conversationId: string) => Promise list: (limit?: number) => Promise> @@ -399,6 +415,7 @@ export interface LocalChatApi { }) => Promise } changes: { + onChanged: (listener: (event: ChangeSetChangeNotice) => void) => () => void overview: (input: ChangeOverviewRequest) => Promise get: (changeSetId: string) => Promise } @@ -428,6 +445,7 @@ export interface LocalChatApi { deny: (approvalId: string) => Promise } composerResources: { + onChanged: (listener: (change: ComposerResourcesChanged) => void) => () => void accept: (input: BuddyComposerResourceAccept) => Promise complete: (input: BuddyComposerResourceComplete) => Promise fail: (input: BuddyComposerResourceTarget) => Promise diff --git a/apps/buddy/extensions/README.md b/apps/buddy/extensions/README.md index d2ed156c..9094ef6e 100644 --- a/apps/buddy/extensions/README.md +++ b/apps/buddy/extensions/README.md @@ -18,6 +18,8 @@ node extensions/tools.mjs pack .output/extensions/my-plugin .output/extensions/m `icon` 可指定包内 SVG、PNG、JPEG 或 WebP 图标(最多 64 KiB),用于导航、插件卡片与安装弹窗。未提供图标时显示默认线框图标。 +API 3 还支持原生设置模块、分组和单项,以及带权限的 Agent 指令、工具、模型请求和当前任务标题更新。先通过能力目录确认宿主支持,再声明最低应用版本;接口与示例见 [Agent 与设置](../service/resources/skills/plugin-creator/references/agent-settings.md)。 + `format: "source"` 支持自包含 TS/JS 源码。Lexora 使用固定编译器,不执行 npm 安装、脚本或第三方构建配置。只能导入包内相对路径;含运行时 npm 依赖的插件需要作者预先打包为 `format: "compiled"`。已编译包直接安装,源码包确认权限后编译,失败不会替换已安装版本。 宿主入口导出 `activate(context)`,可选 `deactivate()`。通过 `context.commands.register` 注册清单中声明的命令;订阅资源使用 `context.subscriptions` 清理。`permissions.notifications` 开放系统通知,`permissions.schedules` 开放 Lexora 运行期间的定时命令。定时配置跨重启保存,休眠或退出期间错过的提醒不会补发,卸载会删除定时任务。 @@ -28,7 +30,7 @@ node extensions/tools.mjs pack .output/extensions/my-plugin .output/extensions/m API 1 不开放 Node、shell 或任意 Desktop API。文件只读,仅能访问用户选中的文件;HTTPS GET 仅访问已确认的来源,不共享浏览器登录态。`context.storage` 保存私有 JSON,`dataVersion` 改变时入口提供 `migrate(previous, from, to)`,失败保留旧数据。上下文视图可用 `setState` 保存标签状态,独立配置页通过宿主命令保存设置。 -更新安装后,当前版本继续运行,点击“重启扩展”应用更新。禁用停止插件,重新启用保留配置;卸载保留私有 JSON 与上下文视图位置,但移除代码和定时任务。 +更新安装后,当前版本继续运行,点击“重启扩展”应用更新。禁用停止插件,重新启用保留配置;普通卸载保留插件设置、私有 JSON 与上下文视图位置,但移除代码和定时任务。“卸载并清理”还会删除设置、私有数据和视图状态,不删除任务内容或用户文件。 需要独立开发窗口时,先构建 Electron,再运行 `node extensions/tools.mjs dev /path/to/plugin`。开发窗口使用单独的临时配置;修改后重新加载构建目录并重启扩展。SDK 快照可以通过 `node extensions/tools.mjs export-sdk /path/to/plugin-sdk/sdk` 导出。 diff --git a/apps/buddy/extensions/tools.mjs b/apps/buddy/extensions/tools.mjs index baad4bc4..ca6a85e2 100644 --- a/apps/buddy/extensions/tools.mjs +++ b/apps/buddy/extensions/tools.mjs @@ -29,7 +29,7 @@ async function run() { const entries = [manifest.entry, ...manifest.contributes.views.map(view => view.entry)].filter(Boolean) for (const entry of entries) assert((await fs.stat(path.join(source, entry))).isFile(), `Missing entry: ${entry}`) if (command === 'check') { - process.stdout.write(`${manifest.id}@${manifest.version}: compatible with Lexora ${version}, API 1` + '\n') + process.stdout.write(`${manifest.id}@${manifest.version}: compatible with Lexora ${version}, API ${manifest.apiVersion}` + '\n') } else if (command === 'pack') { assert(output, 'An output package path is required') diff --git a/apps/buddy/package.json b/apps/buddy/package.json index 1e33a158..d59912c9 100644 --- a/apps/buddy/package.json +++ b/apps/buddy/package.json @@ -56,6 +56,7 @@ "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "diff": "^8.0.4", + "eventemitter2": "6.4.9", "fflate": "0.8.3", "linkedom": "^0.18.13", "minisearch": "7.2.0", diff --git a/apps/buddy/platform/extensions/ExtensionPackageStore.ts b/apps/buddy/platform/extensions/ExtensionPackageStore.ts index 96ededa2..e3c8e281 100644 --- a/apps/buddy/platform/extensions/ExtensionPackageStore.ts +++ b/apps/buddy/platform/extensions/ExtensionPackageStore.ts @@ -1,4 +1,5 @@ import type { ExtensionManifest } from '../../shared/extensions/extensionManifest' +import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from '../../shared/extensions/extensionSettings' import type { JsonValue } from '../../shared/workbench/workbenchState' import type { ExtensionCompiler } from './compileExtensionSource' import { Buffer } from 'node:buffer' @@ -10,6 +11,7 @@ import { gt, satisfies } from 'semver' import { z } from 'zod' import { extensionResourceSchema } from '../../shared/extensions/extensionApi' import { addedExtensionPermissions, extensionCompatible, extensionIdSchema, extensionManifestSchema } from '../../shared/extensions/extensionManifest' +import { extensionConfigurationSchema, resolveExtensionConfiguration } from '../../shared/extensions/extensionSettings' import { spaceFileTargetSchema } from '../../shared/spaces/spaceFileApi' import { EXTENSION_PACKAGE_LIMIT, readExtensionDirectory, readExtensionFile, readExtensionJson, sha256, unpackExtension, validateExtensionFiles, verifiedExtensionAsset, writeExtensionJson } from './extensionFiles' import { extensionIconUrl } from './extensionIcon' @@ -33,7 +35,7 @@ export class ExtensionPackageStore { readonly #icons = new Map }>() #index: z.infer = { version: 1, installed: {} } #tail = Promise.resolve() - readonly #dataWrites = new Map>() + readonly #dataWrites = new Map>() #loaded = false constructor(root: string, appVersion: string) { @@ -216,11 +218,19 @@ export class ExtensionPackageStore { } async removePackages(id: string): Promise { - if (this.installed[id]) - throw new Error('EXTENSION_STILL_INSTALLED') + if (this.installed[id]?.enabled) + throw new Error('EXTENSION_STILL_ENABLED') await rm(join(this.root, 'packages', extensionIdSchema.parse(id)), { recursive: true, force: true }) } + async removeData(id: string): Promise { + const target = extensionIdSchema.parse(id) + if (this.installed[target]?.enabled) + throw new Error('EXTENSION_STILL_ENABLED') + await this.#dataWrites.get(target)?.catch(() => {}) + await rm(join(this.root, 'data', target), { recursive: true, force: true }) + } + packageRoot(pkg: ExtensionPackage): string { return join(this.root, 'packages', pkg.manifest.id, pkg.revision) } @@ -244,10 +254,13 @@ export class ExtensionPackageStore { return url } - async grant(id: string, target: z.infer) { - const resource = { id: randomUUID(), name: target.path.split('/').at(-1) ?? target.path } - await writeExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'resources', `${resource.id}.json`), { resource, target }) - return resource + grant(id: string, target: z.infer, assertCurrent: () => void = () => {}) { + return this.#writeData(id, async () => { + assertCurrent() + const resource = { id: randomUUID(), name: target.path.split('/').at(-1) ?? target.path } + await writeExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'resources', `${resource.id}.json`), { resource, target }, assertCurrent) + return resource + }) } async resolveGrant(id: string, resourceId: string) { @@ -271,17 +284,51 @@ export class ExtensionPackageStore { } } + async configuration(id: string): Promise { + const snapshot = await this.configurationSnapshot(id) + if (snapshot.invalidKeys.length) + throw new Error('EXTENSION_CONFIGURATION_INVALID') + return snapshot.values + } + + async configurationSnapshot(id: string): Promise { + const manifest = this.installed[id]?.current.manifest + if (!manifest) + throw new Error('EXTENSION_NOT_INSTALLED') + const stored = await this.#storedConfiguration(id) + return resolveExtensionConfiguration(manifest.contributes.settings.items, stored) + } + + async saveConfiguration(id: string, value: ExtensionConfiguration): Promise { + await this.#writeData(id, async () => writeExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'configuration.json'), { ...await this.#storedConfiguration(id), ...value })) + } + + async #storedConfiguration(id: string): Promise { + try { + return extensionConfigurationSchema.parse(await readExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'configuration.json'))) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw new Error('EXTENSION_CONFIGURATION_UNREADABLE') + return {} + } + } + async saveData(id: string, value: JsonValue, version: number, assertCurrent: () => void = () => {}): Promise { if (Buffer.byteLength(JSON.stringify(value)) > 262144) throw new Error('EXTENSION_DATA_LIMIT') - const operation = (this.#dataWrites.get(id) ?? Promise.resolve()).catch(() => {}).then(async () => { + await this.#writeData(id, async () => { assertCurrent() await writeExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'state.previous.json'), await this.data(id), assertCurrent) await writeExtensionJson(join(this.root, 'data', extensionIdSchema.parse(id), 'state.json'), { version, value }, assertCurrent) }) + } + + async #writeData(id: string, write: () => Promise): Promise { + const operation = (this.#dataWrites.get(id) ?? Promise.resolve()).catch(() => {}).then(write) this.#dataWrites.set(id, operation) try { - await operation + return await operation } finally { if (this.#dataWrites.get(id) === operation) diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 78d085b3..e4c0495f 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -1,24 +1,32 @@ +import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../shared/extensions/extensionAgent' import type { ExtensionMenuInvocation, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' import type { ExtensionResourceSelection } from '../../shared/extensions/extensionResources' +import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from '../../shared/extensions/extensionSettings' import type { SpaceFileTarget } from '../../shared/spaces/spaceFileApi' import type { WorkbenchPaneSnapshot } from '../../shared/workbench/workbenchInteraction' import type { JsonValue } from '../../shared/workbench/workbenchState' import type { ExtensionCompiler } from './compileExtensionSource' import type { ExtensionPackage, ExtensionPackageStore } from './ExtensionPackageStore' +import type { ExtensionConfigurationApplication, ExtensionServiceChange, ExtensionServiceFact, ExtensionServiceSnapshot } from './ExtensionServiceEvents' import { randomUUID } from 'node:crypto' import { basename } from 'node:path' import { z } from 'zod' +import { Emitter } from '../../shared/events/Emitter' +import { copyEventSnapshot } from '../../shared/events/eventSnapshot' +import { extensionAgentRequestSchema } from '../../shared/extensions/extensionAgent' +import { extensionAgentCapabilities } from '../../shared/extensions/extensionAgentCapabilities' import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi' import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog' import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest' import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' +import { extensionConfigurationAppliedSchema, resolveExtensionConfiguration, validateExtensionSetting } from '../../shared/extensions/extensionSettings' import { workbenchHitRegionsSchema } from '../../shared/workbench/workbenchInteraction' import { controlProposalSchema, extensionPresentationRequestSchema } from '../../shared/workbench/workbenchUi' import { publicWebUrl, readResponseBytes } from '../network/publicWebTransport' import { ExtensionCatalogService } from './ExtensionCatalogService' -import { unpackExtension } from './extensionFiles' +import { sha256, unpackExtension } from './extensionFiles' import { ExtensionInstallations } from './ExtensionInstallations' import { extensionActivationOrder } from './ExtensionPackageStore' import { ExtensionResourceWriter } from './ExtensionResourceWriter' @@ -35,7 +43,9 @@ export interface ExtensionServicePorts { workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => Promise readText: (target: SpaceFileTarget, signal: AbortSignal) => Promise get: (url: string, init: { signal: AbortSignal }) => Promise - changed: () => void + changed?: () => void + agentChanged?: () => void + agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise notify?: (id: string, notification: { title: string, body: string }) => boolean compile?: ExtensionCompiler selectResources?: (name: string, selection: ExtensionResourceSelection & { directory?: boolean }, signal: AbortSignal) => Promise @@ -49,6 +59,7 @@ interface RunningExtension { ready: Promise active: boolean inFlight: number + drained: Set<() => void> panesPending?: boolean } interface RunningView { @@ -63,6 +74,10 @@ interface RunningView { } export class ExtensionService { + readonly #changes = new Emitter(() => console.error('EXTENSION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #configurationApplications = new Map() + #revision = 0 readonly store: ExtensionPackageStore readonly scheduler: ExtensionScheduler readonly installations: ExtensionInstallations @@ -75,17 +90,32 @@ export class ExtensionService { readonly #diagnostics = new Map>() readonly #notificationTimes = new Map() readonly #interactions = new Map() + readonly #agentInvocations = new Map() + readonly #stopping = new Set>() + readonly #retired = new Set() + readonly #viewRequests = new Set>() + readonly #viewCleanups = new Set>() + #viewCleanupError: Error | undefined + #agentDescriptors: ExtensionAgentDescriptor[] = [] + #agentFingerprint = '' + #agentRefreshing = Promise.resolve() #panes: WorkbenchPaneSnapshot[] = [] #loading: Promise | undefined #mutating = Promise.resolve() #compiling = Promise.resolve() #disposed = false + #disposal: Promise | undefined #epoch = 0 constructor(store: ExtensionPackageStore, ports: ExtensionServicePorts) { this.store = store this.#ports = ports - this.installations = new ExtensionInstallations(store.root, ports.changed) + this.onDidChange((change) => { + ports.changed?.() + if (change.kind === 'contributions' && !change.initial) + ports.agentChanged?.() + }) + this.installations = new ExtensionInstallations(store.root, () => this.#publish({ kind: 'installation-progress' })) this.catalog = new ExtensionCatalogService(store.root, store.appVersion, ports.get) this.scheduler = new ExtensionScheduler(store.root, { available: (id, command) => { @@ -105,10 +135,29 @@ export class ExtensionService { }) } + get snapshot(): ExtensionServiceSnapshot { + return copyEventSnapshot({ + revision: this.#revision, + extensions: Object.entries(this.store.installed).map(([id, installed]) => ({ + id, + enabled: installed.enabled, + packageRevision: installed.current.revision, + pendingRevision: installed.pending?.revision ?? null, + generation: this.#running.get(id)?.generation ?? null, + active: this.#running.get(id)?.active ?? false, + errorCode: this.#diagnostics.get(id)?.error ?? null, + configuration: this.#configurationApplications.get(id) ?? null, + })), + contributions: this.#agentDescriptors, + views: [...this.#views.values()].map(view => ({ viewId: view.input.viewId, extensionId: view.input.extensionId, generation: view.running.generation, ready: view.ready, errorCode: view.error })), + }) + } + async initialize(): Promise { await (this.#loading ??= this.store.load().then(async () => { await this.installations.load() await this.scheduler.load() + await this.#refreshAgentContributions(false) })) } @@ -148,6 +197,152 @@ export class ExtensionService { } } + async configuration(id: string): Promise { + await this.initialize() + await this.#mutating + return this.store.configuration(id) + } + + async configurationSnapshot(id: string): Promise { + await this.initialize() + await this.#mutating + return this.store.configurationSnapshot(id) + } + + async configure(id: string, patch: ExtensionConfiguration): Promise { + await this.#mutate(async () => { + const manifest = this.store.installed[id]?.current.manifest + if (!manifest) + throw new Error('EXTENSION_NOT_INSTALLED') + const { values: current } = await this.store.configurationSnapshot(id) + const changedKeys: string[] = [] + for (const [key, value] of Object.entries(patch)) { + const item = manifest.contributes.settings.items.find(item => item.key === key) + if (!item) + throw new Error('EXTENSION_CONFIGURATION_INVALID') + const validated = validateExtensionSetting(item, value) + if (JSON.stringify(current[key]) !== JSON.stringify(validated)) { + current[key] = validated + changedKeys.push(key) + } + } + if (!changedKeys.length) + return + await this.store.saveConfiguration(id, current) + const operationId = randomUUID() + const configurationRevision = sha256(JSON.stringify(current)) + const running = this.#running.get(id) + const application = { operationId, configurationRevision, generation: running?.generation ?? null } + if (this.#diagnostics.get(id)?.error === 'EXTENSION_CONFIGURATION_INVALID') + this.#clearError(id) + this.#configurationChanged(id, { ...application, status: 'committed' }, changedKeys) + for (const invocation of this.#agentInvocations.values()) { + if (invocation.running.package.manifest.id === id) + invocation.abort.abort() + } + if (running?.active && !resolveExtensionConfiguration(manifest.contributes.settings.items, current).invalidKeys.length) { + let timer: ReturnType | undefined + try { + const result = await Promise.race([ + running.host.call('configuration.changed', { configuration: current, changedKeys, operationId, generation: running.generation, configurationRevision }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('EXTENSION_CONFIGURATION_UPDATE_TIMEOUT')), 15000) }), + ]) + this.#assertCurrent(running) + const applied = extensionConfigurationAppliedSchema.parse(result) + if (applied.operationId !== operationId || applied.generation !== running.generation || applied.configurationRevision !== configurationRevision) + throw new Error('EXTENSION_CONFIGURATION_UPDATE_STALE') + if (applied.applied) { + this.#configurationChanged(id, { ...application, status: 'applied' }, changedKeys) + return + } + } + catch (error) { + const code = extensionError(error) + this.#log(id, 'configuration.update-failed', code) + this.#configurationChanged(id, { ...application, status: 'apply-failed' }, changedKeys, code) + } + finally { clearTimeout(timer) } + } + try { + await this.#stopClosure(id) + } + finally { this.#configurationChanged(id, { ...application, status: running ? 'invalidated' : 'deferred' }, changedKeys) } + }) + } + + async agentContributions(): Promise { + await this.initialize() + await this.#mutating + await this.#refreshAgentContributions() + return structuredClone(this.#agentDescriptors) + } + + async #readAgentContributions(): Promise { + if (this.#disposed) + return [] + const result: ExtensionAgentDescriptor[] = [] + for (const [id, record] of Object.entries(this.store.installed)) { + const { manifest, revision } = record.current + const agent = manifest.contributes.agent + if (!agent || !record.enabled || this.#diagnostics.get(id)?.error) + continue + try { + this.#order(id) + const configuration = await this.store.configuration(id) + if (agent.enabledWhen && configuration[agent.enabledWhen] !== true) + continue + result.push({ id, name: manifest.name, revision, configurationRevision: sha256(JSON.stringify(configuration)), agent }) + } + catch {} + } + return this.#disposed ? [] : result + } + + #refreshAgentContributions(notify = true): Promise { + const refresh = this.#agentRefreshing.catch(() => {}).then(async () => { + const descriptors = await this.#readAgentContributions() + const fingerprint = JSON.stringify(descriptors.map(item => [item.id, item.revision, item.configurationRevision]).sort(([a], [b]) => a!.localeCompare(b!))) + const changed = fingerprint !== this.#agentFingerprint + this.#agentDescriptors = descriptors + this.#agentFingerprint = fingerprint + if (changed) + this.#publish({ kind: 'contributions', initial: !notify, descriptors }) + }) + this.#agentRefreshing = refresh + return refresh + } + + async invokeAgent(input: ExtensionAgentInvocation, signal: AbortSignal): Promise { + signal.throwIfAborted() + const contribution = (await this.agentContributions()).find(item => item.id === input.extensionId) + if (!contribution || contribution.revision !== input.revision || contribution.configurationRevision !== input.configurationRevision || !contribution.agent.tools.some(tool => tool.id === input.tool)) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + const running = await this.#activate(input.extensionId) + const current = (await this.agentContributions()).find(item => item.id === input.extensionId) + if (!current || current.revision !== input.revision || current.configurationRevision !== input.configurationRevision) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + const controller = new AbortController() + const abort = AbortSignal.any([signal, running.abort.signal, controller.signal, AbortSignal.timeout(120000)]) + abort.throwIfAborted() + if (this.#agentInvocations.has(input.invocationId) || [...this.#agentInvocations.values()].filter(item => item.running === running).length >= 4) + throw new Error('EXTENSION_REQUEST_LIMIT') + this.#agentInvocations.set(input.invocationId, { running, signal: abort, abort: controller }) + const cancel = () => { + void running.host.call('agent.cancel', { invocationId: input.invocationId }).catch(() => {}) + } + abort.addEventListener('abort', cancel, { once: true }) + try { + const result = await running.host.call('agent.invoke', { invocationId: input.invocationId, tool: input.tool, input: input.input }) + abort.throwIfAborted() + this.#assertCurrent(running) + return extensionJsonSchema.parse(result) + } + finally { + abort.removeEventListener('abort', cancel) + this.#agentInvocations.delete(input.invocationId) + } + } + async review(path: string, development = false) { await this.initialize() const job = this.installations.begin(basename(path), 'validate') @@ -211,6 +406,8 @@ export class ExtensionService { signal.throwIfAborted() this.installations.log(job, 'install', 'installing') const id = await this.store.install(token, signal) + const installed = this.store.installed[id]! + this.#publish({ kind: 'package', action: 'installed', extensionId: id, packageRevision: installed.current.revision, ...(installed.pending ? { pendingRevision: installed.pending.revision } : {}) }) this.#log(id, 'installed') }) this.installations.log(job, 'completed', 'installed') @@ -227,7 +424,10 @@ export class ExtensionService { enable(id: string, enabled: boolean): Promise { return this.#mutate(async () => { + const previous = this.store.installed[id]?.enabled await this.store.enable(id, enabled) + if (previous !== enabled) + this.#publish({ kind: 'enabled', extensionId: id, enabled }) if (!enabled) await this.#stopClosure(id) this.#clearError(id) @@ -241,7 +441,10 @@ export class ExtensionService { if (!this.store.installed[id]) throw new Error('EXTENSION_NOT_INSTALLED') await this.#stopClosure(id) + const previousRevision = this.store.installed[id]!.current.revision await this.store.promote(id) + if (this.store.installed[id]!.current.revision !== previousRevision) + this.#publish({ kind: 'package', action: 'promoted', extensionId: id, packageRevision: this.store.installed[id]!.current.revision }) this.#clearError(id) await this.scheduler.resume() this.#log(id, 'restarted') @@ -251,19 +454,36 @@ export class ExtensionService { revokeResources(id: string): Promise { return this.#mutate(async () => { await this.store.resources.revoke(id) + this.#publish({ kind: 'resources-revoked', extensionId: id }) await this.#stopClosure(id) this.#log(id, 'resources.revoked') }) } - uninstall(id: string): Promise { + uninstall(id: string, clearData = false): Promise { return this.#mutate(async () => { - await this.store.uninstall(id) + const record = this.store.installed[id] + if (!record) + throw new Error('EXTENSION_NOT_INSTALLED') + await this.store.enable(id, false) + if (record.enabled) + this.#publish({ kind: 'enabled', extensionId: id, enabled: false }) await this.#stopClosure(id) await this.store.resources.revoke(id) + this.#publish({ kind: 'resources-revoked', extensionId: id }) await this.scheduler.remove(id) + if (clearData) { + const requestId = randomUUID() + const cleared = await this.#ports.workbench({ kind: 'clear-data', requestId, extensionId: id }, AbortSignal.timeout(10000)) + if (cleared !== requestId) + throw new Error('EXTENSION_DATA_CLEANUP_FAILED') + await this.store.removeData(id) + } await this.store.removePackages(id) + await this.store.uninstall(id) this.#diagnostics.delete(id) + this.#configurationApplications.delete(id) + this.#publish({ kind: 'package', action: 'uninstalled', extensionId: id }) }) } @@ -280,7 +500,7 @@ export class ExtensionService { } updatePanes(panes: WorkbenchPaneSnapshot[]): void { - this.#panes = panes + this.#panes = structuredClone(panes) for (const running of this.#running.values()) { if (running.active) this.#publishPanes(running) @@ -315,10 +535,9 @@ export class ExtensionService { return this.#interactions.delete(id) interaction.abort.abort() - for (const [viewId, view] of this.#views) { + for (const view of this.#views.values()) { if (view.input.interactionId === id) { - view.dispose() - this.#views.delete(viewId) + this.#closeView(view) } } const { running } = interaction @@ -344,7 +563,7 @@ export class ExtensionService { if (target && running.package.manifest.permissions.selectedResource === 'read') { await this.#ports.readText(target, running.abort.signal) this.#assertCurrent(running) - resource = await this.store.grant(running.package.manifest.id, target) + resource = await this.store.grant(running.package.manifest.id, target, () => this.#assertCurrent(running)) } this.#assertCurrent(running) const result = await running.host.call('command', { command, resource, arguments: argumentsValue, invocation }) @@ -381,18 +600,15 @@ export class ExtensionService { if (this.#viewOpenings.get(input.viewId) !== request) throw new Error('EXTENSION_VIEW_EXPIRED') const old = this.#views.get(input.viewId) - old?.dispose() + if (old) + this.#closeView(old) const endpoint = this.#ports.createView(running.package) const abort = new AbortController() const writers = new Map() const session = { id: input.viewId, extensionId: input.extensionId, generation: running.generation, token: endpoint.token, url: endpoint.url } - this.#views.set(input.viewId, { input, session, running, abort, ready: false, error: null, writers, dispose: () => { - abort.abort() - endpoint.dispose() - for (const writer of writers.values()) void writer.dispose().catch(() => {}) - writers.clear() - } }) - return session + this.#views.set(input.viewId, { input: structuredClone(input), session: structuredClone(session), running, abort, ready: false, error: null, writers, dispose: () => endpoint.dispose() }) + this.#publish({ kind: 'view', extensionId: input.extensionId, generation: running.generation, viewId: input.viewId, status: 'opened' }) + return structuredClone(session) } finally { if (this.#viewOpenings.get(input.viewId) === request) @@ -403,12 +619,17 @@ export class ExtensionService { closeView(id: string, generation: string, token: string): void { const view = this.#views.get(id) if (view?.session.generation === generation && view.session.token === token) { - view.dispose() - this.#views.delete(id) + this.#closeView(view) } } - async viewRequest(id: string, generation: string, token: string, method: string, params: JsonValue): Promise { + viewRequest(id: string, generation: string, token: string, method: string, params: JsonValue): Promise { + const pending = Promise.resolve().then(() => this.#viewRequest(id, generation, token, method, params)).finally(() => this.#viewRequests.delete(pending)) + this.#viewRequests.add(pending) + return pending + } + + async #viewRequest(id: string, generation: string, token: string, method: string, params: JsonValue): Promise { const view = this.#views.get(id) if (!view || view.session.generation !== generation || view.session.token !== token) throw new Error('EXTENSION_VIEW_EXPIRED') @@ -459,12 +680,16 @@ export class ExtensionService { result = null } else if (method === 'view.ready') { - view.ready = true + if (!view.ready) { + view.ready = true + this.#publish({ kind: 'view', extensionId: view.input.extensionId, generation, viewId: id, status: 'ready' }) + } result = null } else if (method === 'view.failed') { const failure = z.object({ code: z.enum(['EXTENSION_VIEW_FAILED', 'EXTENSION_VIEW_TIMEOUT']) }).safeParse(params) view.error = failure.success ? failure.data.code : 'EXTENSION_VIEW_FAILED' + this.#publish({ kind: 'view', extensionId: view.input.extensionId, generation, viewId: id, status: 'failed', errorCode: view.error }) this.#log(view.input.extensionId, 'view.failed', failure.success ? failure.data.code : 'EXTENSION_VIEW_FAILED') result = null } @@ -617,12 +842,32 @@ export class ExtensionService { return result } - async dispose(): Promise { + dispose(): Promise { + if (this.#disposal) + return this.#disposal this.#disposed = true - this.scheduler.dispose() - this.catalog.dispose() - await this.installations.dispose() - await this.resetHosts() + this.#disposal = Promise.resolve().then(async () => { + this.scheduler.dispose() + this.catalog.dispose() + const stopping = this.resetHosts() + try { + const results = await Promise.allSettled([stopping, this.#mutating, this.#compiling, ...this.#viewRequests]) + while (this.#stopping.size) + await Promise.allSettled([...this.#stopping]) + await Promise.all([...this.#retired].map(running => running.inFlight ? new Promise(resolve => running.drained.add(resolve)) : Promise.resolve())) + while (this.#viewCleanups.size) + await Promise.all([...this.#viewCleanups]) + await this.#refreshAgentContributions() + await this.installations.dispose() + const stopped = results[0]! + if (stopped.status === 'rejected') + throw stopped.reason + if (this.#viewCleanupError) + throw this.#viewCleanupError + } + finally { this.#changes.dispose() } + }) + return this.#disposal } async resetHosts(): Promise { @@ -650,9 +895,10 @@ export class ExtensionService { } catch (error) { this.#log(dependency, 'activation.failed', extensionError(error)) + this.#publish({ kind: 'host', extensionId: dependency, generation, status: 'failed', errorCode: extensionError(error) }) throw error } - running = { package: pkg, generation, abort, host, ready: Promise.resolve(), active: false, inFlight: 0 } + running = { package: pkg, generation, abort, host, ready: Promise.resolve(), active: false, inFlight: 0, drained: new Set() } this.#running.set(dependency, running) const start = performance.now() const instance = running @@ -660,16 +906,18 @@ export class ExtensionService { running.ready = host.call('activate', { manifest: pkg.manifest, panes: pkg.manifest.apiVersion >= 3 ? this.#panes : [] }).then(() => { this.#assertCurrent(instance) instance.active = true + this.#publish({ kind: 'host', extensionId: dependency, generation, status: 'active', durationMs: Math.round(performance.now() - start) }) this.#publishPanes(instance) this.#log(dependency, 'activated', undefined, Math.round(performance.now() - start)) }).catch(async (error: unknown) => { if (this.#running.get(dependency) === instance) { this.#log(dependency, 'activation.failed', extensionError(error)) + this.#publish({ kind: 'host', extensionId: dependency, generation, status: 'failed', errorCode: extensionError(error) }) await this.#stop(dependency) } throw error }) - this.#ports.changed() + this.#publish({ kind: 'host', extensionId: dependency, generation, status: 'starting' }) } await running.ready this.#assertCurrent(running) @@ -709,7 +957,24 @@ export class ExtensionService { const { id, dataVersion } = running.package.manifest try { let result: JsonValue - if (method === 'storage.get' || method === 'storage.read') { + if (method === 'configuration.get') { + result = await this.store.configuration(id) + } + else if (method === 'agent.request') { + const input = extensionAgentRequestSchema.parse(params) + const invocation = this.#agentInvocations.get(input.invocationId) + const permissions = running.package.manifest.permissions + if (!invocation || invocation.running !== running || !permissions.agent || !this.#ports.agentRequest) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + invocation.signal.throwIfAborted() + const capability = extensionAgentCapabilities[input.method] + if (!capability.permitted(permissions)) + throw new Error('EXTENSION_PERMISSION_DENIED') + capability.input.parse(input.params) + result = await this.#ports.agentRequest(input, invocation.signal) + invocation.signal.throwIfAborted() + } + else if (method === 'storage.get' || method === 'storage.read') { const data = await this.store.data(id) result = method === 'storage.read' ? data : data.value } @@ -832,6 +1097,11 @@ export class ExtensionService { } finally { running.inFlight-- + if (!running.inFlight) { + this.#retired.delete(running) + for (const resolve of running.drained) resolve() + running.drained.clear() + } } } @@ -870,28 +1140,43 @@ export class ExtensionService { if (this.#running.get(id)?.generation !== generation) return this.#log(id, 'host.failed', 'EXTENSION_HOST_CRASHED') - void this.#stopClosure(id) + this.#publish({ kind: 'host', extensionId: id, generation, status: 'failed', errorCode: 'EXTENSION_HOST_CRASHED' }) + void this.#stopClosure(id).catch(() => {}) } - async #stop(id: string): Promise { + #stop(id: string): Promise { + const stopping = this.#stopHost(id).finally(() => this.#stopping.delete(stopping)) + this.#stopping.add(stopping) + return stopping + } + + async #stopHost(id: string): Promise { const running = this.#running.get(id) if (!running) return this.#running.delete(id) + if (running.inFlight) + this.#retired.add(running) running.abort.abort() + this.#publish({ kind: 'host', extensionId: id, generation: running.generation, status: 'stopping' }) for (const [interactionId, interaction] of this.#interactions) { if (interaction.running === running) void this.endInteraction(interactionId).catch(() => {}) } - for (const [viewId, view] of this.#views) { + for (const view of this.#views.values()) { if (view.running === running) { - view.dispose() - this.#views.delete(viewId) + this.#closeView(view) } } - this.#ports.changed() this.scheduler.refresh() - await running.host.dispose() + try { + await running.host.dispose() + this.#publish({ kind: 'host', extensionId: id, generation: running.generation, status: 'stopped' }) + } + catch (error) { + this.#publish({ kind: 'host', extensionId: id, generation: running.generation, status: 'stop-failed', errorCode: extensionError(error) }) + throw error + } } async #stopClosure(id: string): Promise { @@ -912,7 +1197,9 @@ export class ExtensionService { #log(id: string, event: string, code?: string, durationMs?: number): void { const previous = this.#diagnostics.get(id) this.#diagnostics.set(id, { error: event === 'activation.failed' || event === 'host.failed' ? code ?? null : previous?.error ?? null, activationMs: durationMs ?? previous?.activationMs ?? null, logs: [...previous?.logs ?? [], { time: new Date().toISOString(), event, ...(code ? { code } : {}), ...(durationMs !== undefined ? { durationMs } : {}) }].slice(-50) }) - this.#ports.changed() + this.#publish({ kind: 'diagnostic', extensionId: id, event, ...(code ? { errorCode: code } : {}), ...(durationMs === undefined ? {} : { durationMs }) }) + if (event === 'activation.failed' || event === 'host.failed') + void this.#refreshAgentContributions().catch(() => {}) } #clearError(id: string): void { @@ -926,10 +1213,49 @@ export class ExtensionService { await this.initialize() if (this.#disposed) throw new Error('EXTENSION_HOST_STOPPED') - await operation() - this.#ports.changed() + try { + await operation() + } + finally { + await this.#refreshAgentContributions() + } }) this.#mutating = task.catch(() => {}) return task } + + #closeView(view: RunningView): void { + if (this.#views.get(view.input.viewId) !== view) + return + this.#views.delete(view.input.viewId) + view.abort.abort() + const cleanups: Promise[] = [] + try { + view.dispose() + } + catch (error) { cleanups.push(Promise.reject(error)) } + for (const writer of view.writers.values()) cleanups.push(writer.dispose()) + view.writers.clear() + this.#publish({ kind: 'view', extensionId: view.input.extensionId, generation: view.running.generation, viewId: view.input.viewId, status: 'closed' }) + const cleanup = Promise.allSettled(cleanups).then((results) => { + const failure = results.find(result => result.status === 'rejected') + if (failure?.status === 'rejected') { + const errorCode = extensionError(failure.reason) + this.#viewCleanupError ??= new Error(errorCode) + this.#publish({ kind: 'view', extensionId: view.input.extensionId, generation: view.running.generation, viewId: view.input.viewId, status: 'cleanup-failed', errorCode }) + } + }).finally(() => this.#viewCleanups.delete(cleanup)) + this.#viewCleanups.add(cleanup) + } + + #configurationChanged(id: string, application: ExtensionConfigurationApplication, changedKeys: string[], errorCode?: string): void { + const previous = this.#configurationApplications.get(id) + const failure = errorCode ?? (previous?.operationId === application.operationId ? previous.errorCode : undefined) + this.#configurationApplications.set(id, copyEventSnapshot({ ...application, ...(failure ? { errorCode: failure } : {}) })) + this.#publish({ kind: 'configuration', extensionId: id, application, changedKeys, ...(errorCode ? { errorCode } : {}) }) + } + + #publish(fact: ExtensionServiceFact): void { + this.#changes.fire(copyEventSnapshot({ ...fact, revision: ++this.#revision })) + } } diff --git a/apps/buddy/platform/extensions/ExtensionServiceEvents.ts b/apps/buddy/platform/extensions/ExtensionServiceEvents.ts new file mode 100644 index 00000000..ead3a863 --- /dev/null +++ b/apps/buddy/platform/extensions/ExtensionServiceEvents.ts @@ -0,0 +1,38 @@ +import type { EventSnapshot } from '../../shared/events/eventTypes' +import type { ExtensionAgentDescriptor } from '../../shared/extensions/extensionAgent' + +export interface ExtensionConfigurationApplication { + readonly operationId: string + readonly configurationRevision: string + readonly generation: string | null + readonly status: 'committed' | 'applied' | 'deferred' | 'invalidated' | 'apply-failed' + readonly errorCode?: string +} + +export type ExtensionServiceFact + = | { readonly kind: 'package', readonly action: 'installed' | 'promoted' | 'uninstalled', readonly extensionId: string, readonly packageRevision?: string, readonly pendingRevision?: string } + | { readonly kind: 'enabled', readonly extensionId: string, readonly enabled: boolean } + | { readonly kind: 'resources-revoked', readonly extensionId: string } + | { readonly kind: 'configuration', readonly extensionId: string, readonly application: ExtensionConfigurationApplication, readonly changedKeys: readonly string[], readonly errorCode?: string } + | { readonly kind: 'host', readonly extensionId: string, readonly generation: string, readonly status: 'starting' | 'active' | 'failed' | 'stopping' | 'stopped' | 'stop-failed', readonly durationMs?: number, readonly errorCode?: string } + | { readonly kind: 'view', readonly extensionId: string, readonly generation: string, readonly viewId: string, readonly status: 'opened' | 'ready' | 'failed' | 'closed' | 'cleanup-failed', readonly errorCode?: string } + | { readonly kind: 'contributions', readonly initial: boolean, readonly descriptors: EventSnapshot } + | { readonly kind: 'installation-progress' } + | { readonly kind: 'diagnostic', readonly extensionId: string, readonly event: string, readonly errorCode?: string, readonly durationMs?: number } + +export type ExtensionServiceChange = ExtensionServiceFact & { readonly revision: number } +export interface ExtensionServiceSnapshot { + readonly revision: number + readonly extensions: readonly { + readonly id: string + readonly enabled: boolean + readonly packageRevision: string + readonly pendingRevision: string | null + readonly generation: string | null + readonly active: boolean + readonly errorCode: string | null + readonly configuration: ExtensionConfigurationApplication | null + }[] + readonly contributions: EventSnapshot + readonly views: readonly { readonly viewId: string, readonly extensionId: string, readonly generation: string, readonly ready: boolean, readonly errorCode: string | null }[] +} diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionAgent.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionAgent.spec.ts new file mode 100644 index 00000000..057f5dd3 --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionAgent.spec.ts @@ -0,0 +1,306 @@ +import type { JsonValue } from '../../../shared/workbench/workbenchState' +import type { ExtensionServicePorts } from '../ExtensionService' +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { expect, it, vi } from 'vitest' +import { extensionAgentInvocationSchema } from '../../../shared/extensions/extensionAgent' +import { addedExtensionPermissions, extensionManifestSchema } from '../../../shared/extensions/extensionManifest' +import { ExtensionService } from '../ExtensionService' +import { createStore, manifest, reviewPackage } from './fixtures' + +const ports: ExtensionServicePorts = { + createHost: () => ({ call: async () => null, dispose: async () => {}, devtools: () => {} }), + createView: () => { throw new Error('unused') }, + changed: () => {}, + workbench: async () => null, + get: async () => new Response(''), + readText: async () => '', +} +function configuredManifest(id = 'tests.reader', agent = true) { + return manifest({ id, apiVersion: 3, permissions: { agent, tasks: agent ? 'read' : 'none' }, contributes: { + commands: [{ id: `${id}.open`, title: 'Open' }], + ...(agent ? { agent: { enabledWhen: 'enabled', tools: [{ id: `${id}.tool`, title: 'Tool', description: 'Fixture', parameters: { type: 'object' as const, properties: {} } }] } } : {}), + settings: { + groups: [{ id: `${id}.group`, module: 'settings.runtime', title: 'Fixture' }], + items: [ + { id: `${id}.enabled`, key: 'enabled', group: `${id}.group`, type: 'boolean', title: 'Enabled', default: true }, + { id: `${id}.mode`, key: 'mode', group: `${id}.group`, type: 'select', title: 'Mode', default: 'new', options: [{ label: 'New', value: 'new' }, { label: 'Old', value: 'old' }] }, + { id: `${id}.count`, key: 'count', group: `${id}.group`, type: 'number', title: 'Count', default: 1, min: 0, max: 100 }, + ], + }, + } }) +} + +it('preserves incompatible upgrade values and repairs individual fields before restoring agent contributions', async () => { + const { root, store } = await createStore() + const original = configuredManifest() + await store.install((await reviewPackage(root, store, original)).token) + await store.saveConfiguration(original.id, { enabled: true, mode: 'old', count: 90, retired: 'preserve' }) + const upgraded = extensionManifestSchema.parse({ ...original, version: '1.1.0', contributes: { ...original.contributes, settings: { ...original.contributes.settings, items: original.contributes.settings.items.map(item => item.type === 'select' ? { ...item, options: [{ label: 'New', value: 'new' }] } : item.type === 'number' ? { ...item, max: 10 } : item) } } }) + await store.install((await reviewPackage(root, store, upgraded)).token) + const service = new ExtensionService(store, ports) + try { + await service.restart(original.id) + expect(await service.configurationSnapshot(original.id)).toEqual({ values: { enabled: true, mode: 'old', count: 90 }, invalidKeys: ['mode', 'count'] }) + await expect(service.configuration(original.id)).rejects.toThrow('EXTENSION_CONFIGURATION_INVALID') + expect(await service.agentContributions()).toEqual([]) + await service.configure(original.id, { enabled: false }) + await service.configure(original.id, { mode: 'new' }) + expect(await service.configurationSnapshot(original.id)).toEqual({ values: { enabled: false, mode: 'new', count: 90 }, invalidKeys: ['count'] }) + await service.configure(original.id, { count: 1, enabled: true }) + expect((await service.agentContributions()).map(item => item.id)).toEqual([original.id]) + expect(await service.configuration(original.id)).toEqual({ enabled: true, mode: 'new', count: 1 }) + await store.uninstall(original.id) + await store.install((await reviewPackage(root, store, original)).token) + expect(await store.configuration(original.id)).toEqual({ enabled: true, mode: 'new', count: 1 }) + expect((await store.configurationSnapshot(original.id)).invalidKeys).toEqual([]) + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +it('publishes only effective agent catalog changes, leaving UI-only management and no-op writes alone', async () => { + const { root, store } = await createStore() + for (const definition of [configuredManifest(), configuredManifest('tests.ui', false)]) + await store.install((await reviewPackage(root, store, definition)).token) + const snapshots: Promise[] = [] + const service: ExtensionService = new ExtensionService(store, { ...ports, agentChanged: () => { + snapshots.push(service.agentContributions().then(items => items.map(item => item.configurationRevision))) + } }) + try { + const original = await service.agentContributions() + await service.configure('tests.ui', { count: 2 }) + await service.restart('tests.ui') + await service.enable('tests.ui', false) + await service.enable('tests.ui', true) + await service.configure('tests.reader', { count: 1 }) + expect(await service.agentContributions()).toEqual(original) + expect(snapshots).toEqual([]) + await service.configure('tests.reader', { count: 2 }) + expect(await Promise.all(snapshots)).toEqual([(await service.agentContributions()).map(item => item.configurationRevision)]) + expect(await snapshots[0]).not.toEqual(original.map(item => item.configurationRevision)) + await service.configure('tests.reader', { enabled: false }) + expect(await snapshots.at(-1)).toEqual([]) + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +it.each(['hot', 'legacy', 'failed'] as const)('commits configuration with %s update handling while revoking old invocations', async (mode) => { + const { root, store } = await createStore() + const definition = configuredManifest() + await store.install((await reviewPackage(root, store, definition)).token) + let requestSignal: AbortSignal | undefined + let applied: JsonValue = null + let holding = true + const service = new ExtensionService(store, { ...ports, createHost: (_pkg, broker) => ({ + call: async (method, params) => { + if (method === 'agent.invoke') + return broker('agent.request', { invocationId: (params as Record).invocationId, method: 'task.get', params: null }) + if (method === 'configuration.changed') { + if (mode === 'failed') + throw new Error('EXTENSION_CONFIGURATION_UPDATE_FAILED') + applied = params + const change = params as Record + return { operationId: change.operationId!, generation: change.generation!, configurationRevision: change.configurationRevision!, applied: mode === 'hot' } + } + return null + }, + dispose: async () => {}, + devtools: () => {}, + }), agentRequest: async (_input, signal) => { + requestSignal = signal + if (holding) + await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })) + return { title: 'fixture' } + } }) + const invoke = async () => { + const descriptor = (await service.agentContributions())[0]! + return service.invokeAgent(extensionAgentInvocationSchema.parse({ extensionId: descriptor.id, revision: descriptor.revision, configurationRevision: descriptor.configurationRevision, invocationId: randomUUID(), tool: 'tests.reader.tool', input: {} }), new AbortController().signal) + } + try { + const before = invoke().then(() => 'completed', () => 'cancelled') + await vi.waitFor(() => expect(requestSignal).toBeDefined()) + const generation = (await service.list())[0]!.generation + const save = vi.spyOn(store, 'saveConfiguration').mockRejectedValueOnce(new Error('write failed')) + await expect(service.configure(definition.id, { count: 2 })).rejects.toThrow('write failed') + expect(requestSignal!.aborted).toBe(false) + expect((await service.list())[0]!.generation).toBe(generation) + save.mockRestore() + await service.configure(definition.id, { count: 2 }) + expect(await before).toBe('cancelled') + expect(await service.configuration(definition.id)).toMatchObject({ count: 2 }) + expect((await service.list())[0]!.generation).toBe(mode === 'hot' ? generation : null) + if (mode === 'hot') + expect(applied).toMatchObject({ configuration: { enabled: true, mode: 'new', count: 2 }, changedKeys: ['count'], operationId: expect.any(String), generation, configurationRevision: expect.any(String) }) + holding = false + expect(await invoke()).toEqual({ title: 'fixture' }) + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +it('requires explicit API 3 permissions and owned, valid settings contributions', () => { + const definition = manifest({ apiVersion: 3, permissions: { agent: true, models: true, tasks: 'title' }, contributes: { + agent: { enabledWhen: 'enabled', tools: [{ id: 'tests.reader.rename', title: 'Rename', description: 'Rename task', parameters: { type: 'object', properties: {} } }] }, + settings: { groups: [{ id: 'tests.reader.group', module: 'settings.general', title: 'Fixture' }], items: [{ id: 'tests.reader.enabled', key: 'enabled', group: 'tests.reader.group', type: 'boolean', title: 'Enabled', default: true }] }, + } }) + expect(addedExtensionPermissions(manifest().permissions, definition.permissions)).toEqual(expect.arrayContaining(['agent', 'models', 'tasks:title'])) + expect(extensionManifestSchema.safeParse({ ...definition, apiVersion: 2 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...definition, permissions: { agent: false } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...definition, contributes: { ...definition.contributes, agent: { ...definition.contributes.agent, enabledWhen: 'unknown' } } }).success).toBe(false) + for (const item of [ + { ...definition.contributes.settings.items[0], id: 'another.plugin.enabled' }, + { ...definition.contributes.settings.items[0], group: 'another.plugin.group' }, + { ...definition.contributes.settings.items[0], default: 'true' }, + ]) { + expect(extensionManifestSchema.safeParse({ ...definition, contributes: { ...definition.contributes, settings: { ...definition.contributes.settings, items: [item] } } }).success).toBe(false) + } +}) + +it('preserves false, null and retired settings through upgrades and reinstall', async () => { + const { root, store } = await createStore() + const original = manifest({ apiVersion: 3, contributes: { settings: { + groups: [{ id: 'tests.reader.group', module: 'settings.general', title: 'Fixture' }], + items: [ + { id: 'tests.reader.enabled', key: 'enabled', group: 'tests.reader.group', type: 'boolean', title: 'Enabled', default: true }, + { id: 'tests.reader.model', key: 'model', group: 'tests.reader.group', type: 'model', title: 'Model', default: { providerId: 'fixture', modelId: 'default' } }, + { id: 'tests.reader.retired', key: 'retired', group: 'tests.reader.group', type: 'string', title: 'Retired', default: '' }, + ], + } } }) + try { + await store.install((await reviewPackage(root, store, original)).token) + await store.saveConfiguration(original.id, { enabled: false, model: null, retired: 'preserve' }) + const upgraded = extensionManifestSchema.parse({ ...original, version: '1.1.0', contributes: { settings: { ...original.contributes.settings, items: original.contributes.settings.items.slice(0, 2) } } }) + await store.install((await reviewPackage(root, store, upgraded)).token) + await store.promote(original.id) + expect(await store.configuration(original.id)).toEqual({ enabled: false, model: null }) + await store.saveConfiguration(original.id, { enabled: true, model: null }) + await store.uninstall(original.id) + await store.install((await reviewPackage(root, store, original)).token) + expect(await store.configuration(original.id)).toEqual({ enabled: true, model: null, retired: 'preserve' }) + } + finally { await rm(root, { recursive: true, force: true }) } +}) + +it('scopes task/model requests to live invocations, validates configuration and rejects stale or revoked contributions', async () => { + const { root, store } = await createStore() + const definition = manifest({ apiVersion: 3, permissions: { agent: true, tasks: 'title' }, contributes: { + agent: { enabledWhen: 'enabled', tools: [{ id: 'tests.reader.rename', title: 'Rename', description: 'Rename task', parameters: { type: 'object', properties: {} } }] }, + settings: { groups: [{ id: 'tests.reader.group', module: 'settings.general', title: 'Fixture' }], items: [{ id: 'tests.reader.enabled', key: 'enabled', group: 'tests.reader.group', type: 'boolean', title: 'Enabled', default: true }] }, + } }) + await store.install((await reviewPackage(root, store, definition)).token) + let broker!: (method: string, params: unknown) => Promise + let invoke!: (input: Record) => Promise + let capturedSignal: AbortSignal | undefined + const service = new ExtensionService(store, { + createHost: (_pkg, request) => { + broker = request + return { call: async (method, params) => method === 'agent.invoke' ? invoke(params as Record) : null, dispose: async () => {}, devtools: () => {} } + }, + createView: () => { throw new Error('unused') }, + changed: () => {}, + workbench: async () => null, + get: async () => new Response(''), + readText: async () => '', + agentRequest: async (_input, signal) => { + capturedSignal = signal + return { title: 'Scoped task' } + }, + }) + try { + const [descriptor] = await service.agentContributions() + const input = extensionAgentInvocationSchema.parse({ extensionId: descriptor!.id, revision: descriptor!.revision, configurationRevision: descriptor!.configurationRevision, invocationId: randomUUID(), tool: 'tests.reader.rename', input: {} }) + invoke = async (request) => { + await expect(broker('agent.request', { invocationId: randomUUID(), method: 'task.get', params: null })).rejects.toThrow('EXTENSION_AGENT_UNAVAILABLE') + await expect(broker('agent.request', { invocationId: request.invocationId, method: 'models.generateText', params: { prompt: 'private' } })).rejects.toThrow('EXTENSION_PERMISSION_DENIED') + return broker('agent.request', { invocationId: request.invocationId, method: 'task.get', params: null }) + } + expect(await service.invokeAgent(input, new AbortController().signal)).toEqual({ title: 'Scoped task' }) + await expect(broker('agent.request', { invocationId: input.invocationId, method: 'task.get', params: null })).rejects.toThrow('EXTENSION_AGENT_UNAVAILABLE') + await expect(service.configure('tests.reader', { enabled: 'false' })).rejects.toThrow('EXTENSION_CONFIGURATION_INVALID') + expect(await service.configuration('tests.reader')).toEqual({ enabled: true }) + await service.configure('tests.reader', { enabled: false }) + expect(capturedSignal?.aborted).toBe(true) + expect(await service.agentContributions()).toEqual([]) + await expect(service.invokeAgent(input, new AbortController().signal)).rejects.toThrow('EXTENSION_AGENT_UNAVAILABLE') + expect(await store.configuration('tests.reader')).toEqual({ enabled: false }) + await service.enable('tests.reader', false) + await service.enable('tests.reader', true) + expect(await service.agentContributions()).toEqual([]) + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +it.each(['create', 'activate', 'crash'])('withdraws agent contributions and dependents after a host %s failure and restores them on restart', async (failure) => { + const { root, store } = await createStore() + for (const id of ['tests.reader', 'tests.dependent', 'tests.other']) { + const definition = manifest({ id, apiVersion: 3, permissions: { agent: true }, dependencies: id === 'tests.dependent' ? { 'tests.reader': '^1' } : {}, contributes: { + agent: { tools: [{ id: `${id}.tool`, title: 'Fixture', description: 'Fixture agent tool', parameters: { type: 'object', properties: {} } }] }, + } }) + await store.install((await reviewPackage(root, store, definition)).token) + } + let failing = true + let crash = () => {} + const disposed = new Set() + const snapshots: Promise[] = [] + const service: ExtensionService = new ExtensionService(store, { + createHost: (pkg, _broker, failed) => { + if (pkg.manifest.id === 'tests.reader') { + if (failing && failure === 'create') + throw new Error('EXTENSION_HOST_UNAVAILABLE') + crash = failed + } + return { + call: async (method) => { + if (pkg.manifest.id === 'tests.reader' && failing && failure === 'activate' && method === 'activate') + throw new Error('EXTENSION_ACTIVATION_FAILED') + return 'completed' + }, + dispose: async () => { disposed.add(pkg.manifest.id) }, + devtools: () => {}, + } + }, + createView: () => { throw new Error('unused') }, + changed: () => {}, + agentChanged: () => { snapshots.push(service.agentContributions().then(items => items.map(item => item.id))) }, + workbench: async () => null, + get: async () => new Response(''), + readText: async () => '', + }) + try { + const descriptors = await service.agentContributions() + expect(descriptors.map(item => item.id)).toEqual(['tests.reader', 'tests.dependent', 'tests.other']) + const descriptor = descriptors.find(item => item.id === 'tests.dependent')! + const invocation = extensionAgentInvocationSchema.parse({ extensionId: descriptor.id, revision: descriptor.revision, configurationRevision: descriptor.configurationRevision, invocationId: randomUUID(), tool: 'tests.dependent.tool', input: {} }) + if (failure === 'crash') { + expect(await service.invokeAgent(invocation, new AbortController().signal)).toBe('completed') + expect(snapshots).toEqual([]) + crash() + await vi.waitFor(() => expect([...disposed].sort()).toEqual(['tests.dependent', 'tests.reader'])) + } + else { + await expect(service.invokeAgent(invocation, new AbortController().signal)).rejects.toThrow('EXTENSION_') + } + await service.agentContributions() + expect(await Promise.all(snapshots)).toEqual([['tests.other']]) + await expect(service.invokeAgent(invocation, new AbortController().signal)).rejects.toThrow('EXTENSION_AGENT_UNAVAILABLE') + failing = false + await service.restart('tests.reader') + expect(await snapshots.at(-1)).toEqual(['tests.reader', 'tests.dependent', 'tests.other']) + expect(await service.invokeAgent(invocation, new AbortController().signal)).toBe('completed') + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index a5983641..b504f643 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -2,7 +2,7 @@ import type { JsonValue } from '../../../shared/workbench/workbenchState' import type { ExtensionServicePorts } from '../ExtensionService' import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' -import { readFile, rm, writeFile } from 'node:fs/promises' +import { readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { ExtensionService } from '../ExtensionService' @@ -40,7 +40,7 @@ async function fixture(overrides: Partial = {}, initialMa createView: () => ({ token: randomUUID(), url: 'lexora-extension://fixture/__view.html', dispose: () => {} }), workbench: async (event) => { events.push(event) - return event.kind === 'placement' ? event.requestId : event.kind === 'interaction' ? event.interactionId : event.kind === 'state' || event.kind === 'regions' || event.kind === 'activity' ? event.viewId : randomUUID() + return event.kind === 'placement' || event.kind === 'clear-data' ? event.requestId : event.kind === 'interaction' ? event.interactionId : event.kind === 'state' || event.kind === 'regions' || event.kind === 'activity' ? event.viewId : randomUUID() }, readText: async target => `Content of ${target.path}`, get: async () => new Response('network'), @@ -52,6 +52,62 @@ async function fixture(overrides: Partial = {}, initialMa } const target = { spaceId: 'space', directoryId: 'directory', revision: 4, path: 'README.md' } +it.each([false, true])('uninstalls with clearData=%s while preserving other plugins and external files', async (clearData) => { + const { root, store, service, hosts } = await fixture() + const id = 'tests.reader' + const another = 'tests.reader-other' + await store.install((await reviewPackage(root, store, manifest({ id: another }))).token) + await store.saveData(another, { retained: true }, 1) + await store.saveData(id, { old: true }, 1) + await store.saveConfiguration(id, { enabled: false }) + const external = join(root, 'user-document.txt') + await writeFile(external, 'user content') + await store.resources.grant(id, [external], () => {}) + if (process.platform !== 'win32') + await symlink(external, join(store.root, 'data', id, 'external-link')) + await service.execute(id, `${id}.open`, null) + const write = hosts[0]!.broker('storage.set', { value: { current: true }, version: 1 }) + const completed = Promise.allSettled([write]) + await service.uninstall(id, clearData) + await completed + await expect(hosts[0]!.broker('storage.set', { value: 'late', version: 1 })).rejects.toThrow('EXTENSION_HOST_STOPPED') + expect((await service.list()).map(plugin => plugin.manifest.id)).toEqual([another]) + expect(await store.data(another)).toEqual({ version: 1, value: { retained: true } }) + expect(await readFile(external, 'utf8')).toBe('user content') + if (clearData) + await expect(readFile(join(store.root, 'data', id, 'configuration.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + else + expect(JSON.parse(await readFile(join(store.root, 'data', id, 'configuration.json'), 'utf8'))).toEqual({ enabled: false }) + expect(await store.data(id)).toEqual(clearData ? { version: 0, value: {} } : expect.objectContaining({ version: 1 })) + await store.install((await reviewPackage(root, store)).token) + expect(await store.resources.list(id)).toEqual([]) + expect(await store.data(id)).toEqual(clearData ? { version: 0, value: {} } : expect.objectContaining({ version: 1 })) +}) + +it('keeps a stopped installation retryable when workbench cleanup fails', async () => { + let fail = true + const { store, service } = await fixture({ workbench: async event => fail ? null : event.requestId }) + await store.saveData('tests.reader', { retained: true }, 1) + await expect(service.uninstall('tests.reader', true)).rejects.toThrow('EXTENSION_DATA_CLEANUP_FAILED') + expect((await service.list())[0]).toMatchObject({ enabled: false, state: 'disabled' }) + expect(await store.data('tests.reader')).toEqual({ version: 1, value: { retained: true } }) + fail = false + await service.uninstall('tests.reader', true) + expect(await service.list()).toEqual([]) + expect(await store.data('tests.reader')).toEqual({ version: 0, value: {} }) +}) + +it.each(['removeData', 'removePackages'] as const)('keeps the installation retryable after %s fails', async (operation) => { + const { store, service } = await fixture() + await store.saveData('tests.reader', { saved: true }, 1) + vi.spyOn(store, operation).mockRejectedValueOnce(new Error('fixture permission denied')) + await expect(service.uninstall('tests.reader', true)).rejects.toThrow('fixture permission denied') + expect((await service.list())[0]).toMatchObject({ enabled: false, state: 'disabled' }) + await service.uninstall('tests.reader', true) + expect(await service.list()).toEqual([]) + expect(await store.data('tests.reader')).toEqual({ version: 0, value: {} }) +}) + it('publishes static contributions without starting code and deduplicates concurrent activation', async () => { const { service, hosts } = await fixture() expect((await service.list())[0]?.state).toBe('inactive') @@ -337,6 +393,8 @@ it('keeps export permission separate from reads and expires writers with their o await request('resources.writeChunk', { id, offset: 0, base64: Buffer.from('new!').toString('base64') }) f.service.closeView(owner.id, owner.generation, owner.token) await expect(request('resources.commitSave', { id })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + await f.service.dispose() + expect((await readdir(f.root)).filter(name => name.startsWith('.lexora-'))).toEqual([]) expect(await readFile(destination, 'utf8')).toBe('original') }) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts new file mode 100644 index 00000000..ecd96277 --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts @@ -0,0 +1,107 @@ +import type { ExtensionServicePorts } from '../ExtensionService' +import type { ExtensionServiceChange } from '../ExtensionServiceEvents' +import { rm } from 'node:fs/promises' +import { deferred } from '@buddy-tests/deferred' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { ExtensionService } from '../ExtensionService' +import { createStore, manifest, reviewPackage } from './fixtures' + +async function fixture(call?: ExtensionServicePorts['createHost']) { + const { root, store } = await createStore() + const definition = manifest({ id: 'tests.events', apiVersion: 3, contributes: { commands: [{ id: 'tests.events.open', title: 'Open' }], settings: { groups: [{ id: 'tests.events.group', module: 'settings.runtime', title: 'Settings' }], items: [{ id: 'tests.events.name', group: 'tests.events.group', key: 'name', title: 'Name', type: 'string', default: 'fixture-private-initial' }] } } }) + await store.install((await reviewPackage(root, store, definition)).token) + const service = new ExtensionService(store, { createHost: call ?? (() => ({ call: async () => null, dispose: async () => {}, devtools() {} })), createView: () => { + throw new Error('unused') + }, get: async () => new Response(''), readText: async () => '', workbench: async () => null }) + const changes: ExtensionServiceChange[] = [] + service.onDidChange(change => changes.push(change)) + onTestFinished(async () => { + await service.dispose() + await rm(root, { recursive: true, force: true }) + }) + await service.initialize() + return { service, store, changes } +} + +afterEach(() => vi.useRealTimers()) + +describe('extension service facts', () => { + it('publishes committed configuration after persistence, skips no-ops and preserves the accepted state after a failed write', async () => { + const { service, store, changes } = await fixture() + changes.length = 0 + await service.configure('tests.events', { name: 'fixture-private-initial' }) + expect(changes.filter(change => change.kind === 'configuration')).toEqual([]) + vi.spyOn(store, 'saveConfiguration').mockRejectedValueOnce(new Error('fixture-private-write-failure')) + await expect(service.configure('tests.events', { name: 'fixture-private-next' })).rejects.toThrow('fixture-private-write-failure') + expect(changes.filter(change => change.kind === 'configuration')).toEqual([]) + expect(await service.configuration('tests.events')).toEqual({ name: 'fixture-private-initial' }) + await service.configure('tests.events', { name: 'fixture-private-next' }) + const configuration = changes.filter(change => change.kind === 'configuration') + expect(configuration.map(change => change.application.status)).toEqual(['committed', 'deferred']) + expect(service.snapshot.extensions[0]?.configuration).toMatchObject({ status: 'deferred', operationId: configuration[0]!.application.operationId }) + expect(JSON.stringify(configuration)).not.toContain('fixture-private') + expect(Object.isFrozen(service.snapshot.extensions[0]!.configuration)).toBe(true) + }) + + it.each(['retired', 'timeout'] as const)('keeps a saved configuration while fencing a %s application result', async (mode) => { + const applyStarted = deferred() + const apply = deferred['call']>>>() + let acknowledgement!: { operationId: string, generation: string, configurationRevision: string, applied: boolean } + const { service, changes } = await fixture(() => ({ + call: async (method, params) => { + if (method === 'configuration.changed') { + acknowledgement = { ...params as unknown as typeof acknowledgement, applied: true } + applyStarted.resolve() + return apply.promise + } + return null + }, + dispose: async () => {}, + devtools() {}, + })) + await service.execute('tests.events', 'tests.events.open', null) + const generation = service.snapshot.extensions[0]!.generation + if (mode === 'timeout') + vi.useFakeTimers() + const configuring = service.configure('tests.events', { name: 'fixture-private-next' }) + await applyStarted.promise + if (mode === 'retired') { + await service.resetHosts() + apply.resolve({ operationId: acknowledgement.operationId, generation: acknowledgement.generation, configurationRevision: acknowledgement.configurationRevision, applied: true }) + } + else { + await vi.advanceTimersByTimeAsync(15000) + } + await configuring + expect(await service.configuration('tests.events')).toEqual({ name: 'fixture-private-next' }) + const phases = changes.filter(change => change.kind === 'configuration').map(change => change.application.status) + expect(phases).toEqual(['committed', 'apply-failed', 'invalidated']) + expect(service.snapshot.extensions[0]!.generation).toBeNull() + expect(service.snapshot.extensions[0]!.configuration).toMatchObject({ status: 'invalidated', generation, errorCode: mode === 'timeout' ? 'EXTENSION_CONFIGURATION_UPDATE_TIMEOUT' : 'EXTENSION_HOST_STOPPED' }) + expect(changes.filter(change => change.kind === 'host').filter(change => change.generation === generation).map(change => change.status)).toEqual(['starting', 'active', 'stopping', 'stopped']) + if (mode === 'timeout') { + apply.resolve({ operationId: acknowledgement.operationId, generation: acknowledgement.generation, configurationRevision: acknowledgement.configurationRevision, applied: true }) + await Promise.resolve() + expect(changes.filter(change => change.kind === 'configuration' && change.application.status === 'applied')).toEqual([]) + } + }) + + it('retains generation invalidation when the retired host fails to stop', async () => { + const { service, changes } = await fixture(() => ({ + call: async (method, params) => { + if (method !== 'configuration.changed') + return null + const input = params as { operationId: string, generation: string, configurationRevision: string } + return { operationId: input.operationId, generation: input.generation, configurationRevision: input.configurationRevision, applied: false } + }, + dispose: async () => { throw new Error('EXTENSION_HOST_STOP_FAILED') }, + devtools() {}, + })) + await service.execute('tests.events', 'tests.events.open', null) + await expect(service.configure('tests.events', { name: 'fixture-private-next' })).rejects.toThrow('EXTENSION_HOST_STOP_FAILED') + expect(await service.configuration('tests.events')).toEqual({ name: 'fixture-private-next' }) + expect(service.snapshot.extensions[0]).toMatchObject({ generation: null, active: false, configuration: { status: 'invalidated' } }) + expect(changes.filter(change => change.kind === 'configuration').map(change => change.application.status)).toEqual(['committed', 'invalidated']) + expect(changes.filter(change => change.kind === 'host').at(-1)).toMatchObject({ status: 'stop-failed', errorCode: 'EXTENSION_HOST_STOP_FAILED' }) + }) +}) diff --git a/apps/buddy/platform/extensions/observeExtensionDiagnostics.ts b/apps/buddy/platform/extensions/observeExtensionDiagnostics.ts new file mode 100644 index 00000000..08968b9b --- /dev/null +++ b/apps/buddy/platform/extensions/observeExtensionDiagnostics.ts @@ -0,0 +1,35 @@ +import type { ApplicationDiagnosticReporter } from '../../shared/diagnostics/applicationDiagnostic' +import type { ExtensionService } from './ExtensionService' + +export function observeExtensionDiagnostics(service: ExtensionService, report: ApplicationDiagnosticReporter) { + return service.onDidChange((change) => { + const common = { revision: change.revision, component: 'desktop.extensions', ...('extensionId' in change ? { extensionId: change.extensionId } : {}) } + switch (change.kind) { + case 'package': + report({ ...common, event: `extensions.package.${change.action}`, level: 'info' }) + break + case 'enabled': + report({ ...common, event: change.enabled ? 'extensions.enabled' : 'extensions.disabled', level: 'info' }) + break + case 'resources-revoked': + report({ ...common, event: 'extensions.resources.revoked', level: 'info' }) + break + case 'configuration': + report({ ...common, event: `extensions.configuration.${change.application.status.replaceAll('-', '_')}`, level: change.errorCode ? 'warn' : 'info', operationId: change.application.operationId, ...(change.application.generation ? { generation: change.application.generation } : {}), count: change.changedKeys.length, ...(change.errorCode ? { errorCode: change.errorCode } : {}) }) + break + case 'host': + report({ ...common, event: `extensions.host.${change.status.replaceAll('-', '_')}`, level: change.errorCode ? 'error' : 'info', generation: change.generation, ...(change.errorCode ? { errorCode: change.errorCode } : {}), ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }) }) + break + case 'view': + report({ ...common, event: `extensions.view.${change.status.replaceAll('-', '_')}`, level: change.errorCode ? 'warn' : 'info', generation: change.generation, ...(change.errorCode ? { errorCode: change.errorCode } : {}) }) + break + case 'contributions': + report({ ...common, event: 'extensions.contributions.accepted', level: 'info', count: change.descriptors.length }) + break + case 'diagnostic': + if (change.event === 'schedule.failed') + report({ ...common, event: 'extensions.schedule.failed', level: 'warn', ...(change.errorCode ? { errorCode: change.errorCode } : {}) }) + break + } + }) +} diff --git a/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts b/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts index ba1f139a..47c7b9ff 100644 --- a/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts +++ b/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts @@ -49,3 +49,40 @@ it('aborts handlers when their connection closes', async () => { await rejected expect(signal?.aborted).toBe(true) }) + +it('keeps notification observers independent from request outcomes and preserves delivery order under reentry', async () => { + const [client, server] = peers() + const seen: string[] = [] + server.onNotification((method) => { + if (method === 'outer') + client.notify('inner', null) + seen.push(`first:${method}`) + throw new Error('fixture observer failure') + }) + server.onNotification(method => seen.push(`second:${method}`)) + server.onRequest('committed', () => ({ committed: true })) + client.notify('outer', null) + expect(seen).toEqual(['first:outer', 'second:outer', 'first:inner', 'second:inner']) + await expect(client.request('committed', null)).resolves.toEqual({ committed: true }) + client.close(new Error('closed')) + server.close(new Error('closed')) +}) + +it('owns nested notification data before observers run without freezing the sender', async () => { + const [client, server] = peers() + const original = { nested: { value: 'committed' } } + const received: unknown[] = [] + server.onNotification((_, params) => { + Reflect.set((params as typeof original).nested, 'value', 'corrupted') + throw new Error('fixture observer failure') + }) + server.onNotification((_, params) => received.push(params)) + client.notify('fact', original) + original.nested.value = 'sender changed' + expect(received).toEqual([{ nested: { value: 'committed' } }]) + expect(Object.isFrozen(original.nested)).toBe(false) + server.onRequest('available', () => true) + await expect(client.request('available', null)).resolves.toBe(true) + client.close(new Error('closed')) + server.close(new Error('closed')) +}) diff --git a/apps/buddy/platform/ipc/runtimeRpcPeer.ts b/apps/buddy/platform/ipc/runtimeRpcPeer.ts index c87e927d..997f45e2 100644 --- a/apps/buddy/platform/ipc/runtimeRpcPeer.ts +++ b/apps/buddy/platform/ipc/runtimeRpcPeer.ts @@ -1,6 +1,8 @@ import type { RuntimeMessageTransport, RuntimeRequestHandler, RuntimeRpcPeerContract, RuntimeRpcPeerOptions } from '../../shared/runtime/rpcPeer' import type { RuntimeWireMessage } from '../../shared/runtime/runtimeProtocol' import { randomUUID } from 'node:crypto' +import { Emitter } from '../../shared/events/Emitter' +import { copyEventSnapshot } from '../../shared/events/eventSnapshot' import { readLocalChatErrorCode } from '../../shared/runtime/localChatError' import { runtimeWireMessageSchema } from '../../shared/runtime/runtimeProtocol' @@ -49,7 +51,7 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { readonly #defaultTimeoutMs: number readonly #onFatalError?: (error: Error) => void readonly #handlers = new Map() - readonly #notifications = new Set<(method: string, params: unknown) => void>() + readonly #notifications = new Emitter<{ method: string, params: unknown }>(() => console.error('RUNTIME_NOTIFICATION_OBSERVER_FAILED')) readonly #pending = new Map() readonly #running = new Map() readonly #unsubscribe: () => void @@ -68,8 +70,8 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { } onNotification(listener: (method: string, params: unknown) => void): () => void { - this.#notifications.add(listener) - return () => this.#notifications.delete(listener) + const subscription = this.#notifications.event(({ method, params }) => listener(method, params)) + return subscription.dispose } onRequest(method: string, handler: RuntimeRequestHandler): () => void { @@ -132,7 +134,7 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { this.#closed = true this.#unsubscribe() this.#handlers.clear() - this.#notifications.clear() + this.#notifications.dispose() for (const pending of this.#pending.values()) { pending.dispose() pending.reject(reason) @@ -223,8 +225,15 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { } #emitNotification(method: string, params: unknown): void { - for (const listener of this.#notifications) - listener(method, params) + let notification: { readonly method: string, readonly params: unknown } + try { + notification = copyEventSnapshot({ method, params }) + } + catch { + this.#fail(new RuntimeProtocolError('Runtime emitted invalid notification data')) + return + } + this.#notifications.fire(notification) } #handleResponse(message: Exclude): void { diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 1123c831..1807bcd8 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -30,6 +30,7 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 继续旧对话创作时,重新读取当前技能与协议。历史消息、旧工具结果和压缩摘要中的命名示例不作为新插件的身份依据;维护已有插件时读取实际源码清单,并保留其中的 ID。 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 + 需要 Agent 指令、模型调用、任务标题或原生设置时,追加读取 [Agent 与设置](references/agent-settings.md),并确认当前宿主能力目录支持。 2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 3. 新建独立插件时通过 `lexora_tool_search` 查找 `lexora_plugin_identity`,传入按功能选定的简短英文 `slug`,将返回的 `id`、`author` 和 `engines` 写入 `extension.json`。作者未指定时省略参数,使用已保存的默认署名;明确不署名时传空字符串。作者名支持中文,不要求用户提供英文名、平台账号或唯一名称,也不从系统用户名、Git 配置或目录推断署名。不要自行编造 ID 或继续生成 `local.*`。修改、升级、换电脑维护及分发沿用源码内已有 ID,不重新调用身份生成工具;包括已有的 `local.*`。只有创建独立副本时生成新 ID 并同步内部引用。 4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;普通挂载需要显式打开;内容插槽和替换控件的启用、回退与配置归属按 [内容插槽](references/slots.md) 实现。现有入口足够时不额外增加设置页。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md b/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md new file mode 100644 index 00000000..6b544c79 --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md @@ -0,0 +1,50 @@ +# Agent 工具与声明式设置 + +这些是 API 3 的增量能力。先查询当前宿主的 `runtime` 能力目录,确认 `agent.tools`、`agent.models`、`agent.task` 或 `settings` 可用,再声明对应的最低 `engines.lexora`。 + +## 设置 + +`contributes.settings` 包含三个可选数组: + +- `modules: [{id,title,order?}]`:设置侧栏中的独立模块。 +- `groups: [{id,module,title,order?}]`:模块中的分组。module 引用自己的模块或能力目录列出的内置模块。 +- `items: [{id,key,group,title,description?,type,default,order?}]`:分组中的单项。group 引用自己的分组或能力目录开放的内置分组。 + +所有 id 以插件 ID 加 `.` 开头且全包唯一;key 在本插件内唯一,使用以小写字母开头的字母数字名称。order 范围 -1000–1000,默认 0。插件不能覆写宿主设置或插入其他插件的设置。 + +type 支持 boolean、string(最长 8192 字符)、number(可声明 min/max)、select(声明 `options:[{label,value}]`)、model。默认值必须符合类型;model 值为 `{providerId,modelId}` 或 null,空值表示采用调用工具的本轮模型。指定模型不可用时会失败,不会静默改用其他模型。 + +宿主自动保存设置,插件用 `context.configuration.get()` 读取,使用 `configuration.onChange` 注册可等待的热应用处理器;`context.events.on('configuration:changed', listener)` 仅观察已经保存的配置;订阅与失败回退见 [作用域事件](events.md)。修改设置取消当前插件调用,Agent 贡献变化从下一轮生效;关闭功能后旧工具也被拒绝。配置与 storage 私有业务数据独立,不维护第二份持久设置真源。已发布 key 保持类型和含义稳定,新增 key 使用 default。升级后不符合新选项或范围的旧值会被保留并在设置中标记,用户可以逐项修改或恢复默认值;修复前 Agent 贡献不可用。禁用和普通卸载保留配置;用户选择“卸载并清理”时删除配置、私有数据和视图状态。 + +## Agent 入口 + +声明 `permissions.agent: true`、宿主 entry,并添加 `contributes.agent`: + +- enabledWhen:可选,引用本插件 boolean 设置,值为 true 时贡献生效。 +- instructions:可选使用指引,最长 8000 字符;`{{.tool}}` 替换为实际工具名,不硬编码运行时名称。 +- tools:`[{id,title,description,parameters}]`,最多 16 个,必须在 activate 中全部注册。 + +parameters 为封闭对象,例如:`{type:"object",properties:{summary:{type:"string",description:"任务摘要"}},required:["summary"],additionalProperties:false}`。支持最多 16 个 string/number/boolean 字段,不支持嵌套 schema 或外部引用。 + +```ts +context.agent.registerTool(`${context.extension.id}.tool`, async (input, invocation) => { + const configuration = await context.configuration.get() + const task = await invocation.task.get() + if (task.titleSource === 'manual') + return { applied: false } + const result = await invocation.models.generateText({ + prompt: String(input.summary), + model: typeof configuration.model === 'object' ? configuration.model : null, + maxTokens: 256, + }) + return invocation.task.rename({ title: result.text.trim(), expectedRevision: task.titleRevision }) +}) +``` + +回调返回 JSON。invocation 仅在本次回调内有效;回调结束、任务取消、插件禁用、更新或崩溃后不能继续使用。遵守 `invocation.signal`,不保留句柄给其他任务或后台定时器。工具失败不应阻断原任务。 + +## 模型与任务权限 + +`permissions.models: true` 开放 `invocation.models.generateText({prompt,system?,model?,maxTokens?})`,返回 `{text,model}`。未指定模型时使用本轮模型,显式指定不可用模型则失败。请求仅有文本,没有工具或隐式对话历史;不允许自定义凭据、URL 或 Provider 参数。凭据留在宿主,用量计入当前运行。最多 4 次模型请求,每次最多 4096 输出 token,整个工具调用限时 120 秒。 + +`permissions.tasks: "read"` 开放 task.get,返回当前任务的 id/title/titleSource/titleRevision;`"title"` 额外开放 rename。标题为单行 1–80 字符,比较版本后返回 `{applied}`。手动标题、历史来源不明的标题、已删除任务和过期版本不被覆盖;改名不更新任务活动时间。API 不接受调用者指定的任务 ID,不读取其他任务或聊天历史,不提供任意字段写入。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index f1b22410..29bad66b 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -1,5 +1,24 @@ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json } +export type ReadonlyJson = string | number | boolean | null | ReadonlyJsonArray | ReadonlyJsonObject +export interface ReadonlyJsonArray extends ReadonlyArray {} +export interface ReadonlyJsonObject { readonly [key: string]: ReadonlyJson } export interface Disposable { dispose: () => void | Promise } +export interface EventSubscription { dispose: () => void } +export interface EventSubscriptionOptions { signal?: AbortSignal, once?: boolean } +export type EventName = Extract +type NamespacePattern = Name extends `${infer Head}:${infer Tail}` ? `${Head}:*` | `${Head}:**` | `${Head}:${NamespacePattern}` : never +export type EventPattern = EventName | '*' | '**' | NamespacePattern> +type EventMatches = Pattern extends '**' ? true + : Pattern extends `${infer Prefix}:**` ? Name extends Prefix | `${Prefix}:${string}` ? true : false + : Pattern extends `${infer Prefix}:*` ? Name extends `${Prefix}:${infer Tail}` ? Tail extends `${string}:${string}` ? false : true : false + : Pattern extends '*' ? Name extends `${string}:${string}` ? false : true : Name extends Pattern ? true : false +export type EventMessage = Pattern extends unknown ? { + [Name in EventName]: EventMatches extends true ? Readonly<{ type: Name, data: Events[Name] }> : never +}[EventName] : never +export interface EventSubscriber { + on: >(patterns: Pattern | readonly Pattern[], listener: (event: EventMessage) => unknown, options?: EventSubscriptionOptions) => EventSubscription +} +export type EventSnapshot = Value extends object ? { readonly [Key in keyof Value]: EventSnapshot } : Value export interface Resource { readonly id: string, readonly name: string } export interface ResourceApi { readText: (resource: Resource) => Promise } export interface LocalFile extends Resource { readonly mimeType: string, readonly size: number, readonly relativePath?: string } @@ -34,8 +53,33 @@ export interface PaneSnapshot { readonly id: string, readonly active: boolean, r export interface Interaction { readonly id: string, readonly signal: AbortSignal, end: () => Promise } export interface PlacementOptions { instanceId?: string, interactionId?: string } export interface HitRegion { id: string, label: string, rect: Rect } +export interface ModelSelection { providerId: string, modelId: string } +export type SettingValue = boolean | string | number | ModelSelection | null +export interface ConfigurationEvents { + 'configuration:changed': { readonly configuration: EventSnapshot>, readonly changedKeys: readonly string[] } +} +export interface WorkbenchPaneEvents { + 'workbench:panes:changed': { readonly panes: readonly PaneSnapshot[] } +} +export interface ExtensionEvents extends ConfigurationEvents, WorkbenchPaneEvents {} +export interface AgentToolContext { + readonly signal: AbortSignal + readonly task: { + get: () => Promise<{ id: string, title: string | null, titleSource: 'manual' | 'fallback' | 'generated', titleRevision: number }> + rename: (input: { title: string, expectedRevision: number }) => Promise<{ applied: boolean }> + } + readonly models: { + generateText: (input: { prompt: string, system?: string, model?: ModelSelection | null, maxTokens?: number }) => Promise<{ text: string, model: ModelSelection }> + } +} export interface ExtensionContext { readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 | 3 } + readonly events: EventSubscriber + readonly configuration: { + get: () => Promise> + onChange: (listener: (configuration: Readonly>) => void | Promise) => Disposable + } + readonly agent: { registerTool: (id: string, execute: (input: Record, context: AgentToolContext) => Json | void | Promise) => Disposable } readonly subscriptions: { add: (disposable: T) => T } readonly commands: { register: (id: string, execute: (context: { resource: Resource | null, arguments: Json, invocation: CommandInvocation | null }) => Json | void | Promise) => Disposable } readonly placements: { show: (id: string, options?: string | PlacementOptions) => Promise, hide: (id: string, options?: string | PlacementOptions) => Promise } @@ -89,6 +133,7 @@ export interface WorkbenchContextSnapshot { readonly pages: readonly { readonly id: string, readonly title: string }[] } export interface ViewContext { + readonly events: EventSubscriber readonly interaction: { readonly id: string setRegions: (regions: readonly HitRegion[]) => Promise @@ -109,7 +154,7 @@ export interface ViewContext { onChange: (listener: (snapshot: ControlSnapshot) => void) => Disposable propose: (value: string, revision?: string) => Promise } | null - readonly environment: { language: string, colorScheme: 'light' | 'dark', colors: Record } + readonly environment: ViewEnvironment onEnvironmentChange: (listener: (environment: ViewContext['environment']) => void) => Disposable readonly apiVersion: 1 | 2 | 3 readonly resource: Resource | null @@ -125,6 +170,35 @@ export interface ViewContext { setState: (state: Json) => Promise setActive: (active: boolean) => Promise } +export interface ViewEnvironment { + readonly language: string + readonly colorScheme: 'light' | 'dark' + readonly colors: Readonly> +} +export interface ViewStateEvents { + 'view:visibility:changed': { readonly visible: boolean } + 'view:environment:changed': { readonly environment: ViewEnvironment } +} +export interface ViewGeometryEvents { + 'view:mount:changed': { readonly mount: MountGeometry } + 'view:anchor:changed': { readonly anchor: AnchorGeometry } +} +export interface ViewMessageEvents { + 'view:message:received': { readonly message: ReadonlyJson } +} +export interface WorkbenchContextEvents { + 'workbench:context:changed': { readonly context: WorkbenchContextSnapshot } +} +export interface ControlEvents { + 'control:changed': { readonly control: ControlSnapshot } +} +export interface InteractionEvents { + 'interaction:activated': { readonly regionId: string, readonly x: number, readonly y: number } +} +export interface ComposerEvents { + 'composer:input:received': { readonly caret?: Rect | null } +} +export interface ViewEvents extends ViewStateEvents, ViewGeometryEvents, ViewMessageEvents, WorkbenchContextEvents, ControlEvents, InteractionEvents, ComposerEvents {} export interface ExtensionModule { activate: (context: ExtensionContext) => void | Promise deactivate?: () => void | Promise diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md index 6fb80f6d..845de123 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -4,6 +4,8 @@ | 用户需要 | 清单与入口 | 状态和边界 | | --- | --- | --- | +| 由主模型主动调用插件、请求模型或修改任务标题 | API 3;contributes.agent 和 agent/models/tasks 权限,见 [Agent 与设置](agent-settings.md) | 工具入口绑定原任务与本轮模型,不导出凭据,不覆盖手动标题 | +| 在原生设置页追加模块、分组或单项 | API 3;contributes.settings,见 [Agent 与设置](agent-settings.md) | 宿主保存配置并渲染控件;插件读取 configuration,不另存设置 | | 对当前文件提供阅读、统计或专用展示 | `selectedResource: "read"`;`resource: "selected-file"` 的视图;宿主可见命令将收到的 `resource` 传给 `views.open`,视图用 `resources.readText(context.resource)` 读取 | 用户先打开文件,再执行插件命令。句柄不包含任意路径,每次打开是独立实例;不能枚举目录、读其他视图的文件或写回文件 | | 输入 `/插件名:命令` 执行插件动作 | API 3 命令声明 `slash`,并在 `activate` 注册处理函数;见 [注册命令](commands.md) | 精确匹配,包内校验注册,跨插件命名空间隔离,不请求模型 | | 需要真实分屏布局或临时覆盖交互 | API 3;见 [交互会话](interactions.md) | 订阅布局;稀疏点击区域或独占交互;宿主提供独立退出入口 | @@ -37,4 +39,4 @@ 验收从用户行为出发,不以采用某个样例的文件名或界面作为成功依据。例如文件阅读工具应验证两份不同文件、空内容、各自筛选状态及重开恢复;网络信息条应验证允许来源、请求失败后的状态、关闭与再次显示;自定义控件应验证当前快照、禁用或隐藏时提交被拒绝,以及故障后内置控件可用。 -对每项需求明确已有 API 是否覆盖。只有当前宿主能力查询返回的正式位置,没有任意 DOM 插槽、父页面 DOM、静默覆写用户文件、通用 IPC 或 Agent 调用权限;核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 +对每项需求明确已有 API 是否覆盖。只有当前宿主能力查询返回的正式位置,没有任意 DOM 插槽、父页面 DOM、静默覆写用户文件或通用 IPC;Agent 能力仅通过声明权限的受控工具入口开放。核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/events.md b/apps/buddy/service/resources/skills/plugin-creator/references/events.md new file mode 100644 index 00000000..26e59489 --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/events.md @@ -0,0 +1,42 @@ +# 作用域事件 + +当前 SDK 中,宿主和视图的 `context.events.on(patterns, listener, options?)` 提供只读订阅,返回可提前释放的订阅句柄。`patterns` 可以是一个名称,也可以是只读数组。订阅随所属宿主或视图销毁释放;可传 `{once:true}` 或 `{signal:AbortSignal}`。已取消的 signal 和空数组不创建订阅。 + +事件使用 `{type,data}`,`type` 是「领域:对象:事实」或「领域:事实」,`data` 使用有含义的命名字段。类型声明根据订阅推导可区分的事件联合,检查 `event.type` 后即可取得对应载荷。支持精确名称、结尾 `:*`(一层)、结尾 `:**`(任意层);单独 `*` 只匹配不含冒号的名称,`**` 匹配当前作用域全部事件。不支持任意正则表达式。 + +```ts +const subscription = context.events.on(['configuration:changed', 'workbench:**'], (event) => { + if (event.type === 'configuration:changed') + updateConfigurationDisplay(event.data.configuration, event.data.changedKeys) + else if (event.type === 'workbench:panes:changed') + updatePanes(event.data.panes) +}) +// 也可提前停止整组订阅。 +subscription.dispose() +``` + +| 作用域 | 事件 | data / 原有入口 | +| --- | --- | --- | +| 宿主 | `configuration:changed` | `{configuration, changedKeys}`,观察已保存配置;实际热应用另用 `configuration.onChange` | +| 宿主 | `workbench:panes:changed` | `{panes}`;`workbench.onPanesChange` | +| 视图 | `view:message:received` | `{message}`,本插件私有广播;`onMessage` | +| 视图 | `workbench:context:changed` | `{context}`,公开上下文;`onWorkbenchChange` | +| 视图 | `view:visibility:changed` | `{visible}`;`onVisibilityChange` | +| 视图 | `view:environment:changed` | `{environment}`,语言与主题;`onEnvironmentChange` | +| 视图 | `view:mount:changed` | `{mount}`,当前挂载几何;`onMountChange` | +| 装饰视图 | `view:anchor:changed` | `{anchor}`,当前锚点几何;`onAnchorChange` | +| 装饰视图 | `composer:input:received` | `{caret?}`,仅活动和可选局部光标矩形,不含输入文本;`onActivity` | +| 控件视图 | `control:changed` | `{control}`,控件快照;`control.onChange` | +| 交互视图 | `interaction:activated` | `{regionId, x, y}`,自己命中区域的局部点击;`interaction.onActivate` | + +旧入口和原有回调载荷继续可用,与事件订阅共用同一来源,API 仍为 3。不要同时订阅新旧入口来处理同一变化。宿主事件只属于当前扩展,视图事件只属于当前实例;私有广播仅分发到本插件当前 generation 的视图。通配符不增加数据权限,`**` 也不包含任务、对话历史、凭据或未授权资源。插件没有发布宿主事件的入口。 + +数组是一个订阅组:同次发布即使同时命中多个 pattern 也只调用一次;`once` 表示整组只接收第一次匹配的事件;`dispose()` 或 signal 取消会释放整组。分别调用两次 `on` 是独立订阅,不互相去重或释放。 + +每次派发固定订阅快照,新加订阅从下次生效,已释放订阅不再调用。`once` 在回调前释放,重入不会重复调用。同一 pattern 按注册顺序调用;不同 pattern 间不要依赖顺序,异步监听器也不保证完成顺序。状态类事件先更新只读快照再通知;订阅不重放历史,初始状态仍通过对应读取接口获取。私有广播的 message 保持 JSON 契约,不用于发布系统事件。 + +普通通知不会等待异步监听器;同步异常和 Promise 拒绝均被隔离。包括 `configuration:changed` 在内,普通订阅及 `**` 都不表示插件已应用配置。 + +需要热应用时显式注册 `context.configuration.onChange(configuration => applyConfiguration(configuration))`,返回的 Promise 应在应用完成后解决。宿主先保存配置并撤销旧调用,再等待这些应用处理器。没有处理器、应用失败或超时会回退到重启,已经保存的配置保留;新的工具调用等待应用或重启完成。迟到的旧代结果不会恢复已撤销的调用。 + +视图初始化使用当前作用域的只读快照,随后接收增量。宿主在权限与实例过滤后确定顺序;初始化期间发生变化、出现缺口或缓冲溢出时,SDK 重新读取快照并通知实际差异,不要求插件实现补偿。一次恢复最多尝试三次,失败后较新的增量会触发下一轮有界恢复。输入活动、私有消息和点击不提供历史重放;已初始化实例收到但尚未交付的这些通知会在恢复后交付一次,旧实例与初始化快照之前的通知不重放。命令超时表示结果尚未确认,读取当前状态后再决定下一步,不自动重做可能已经执行的操作。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 77a4a3b7..0c98a774 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -35,6 +35,8 @@ | `views` | 数组,每项必填 `id`、`title`、`entry`;`resource` 为 `selected-file`(默认)或 `none`;`location` 默认 `context`;`stateVersion` 为正整数,默认 1 | | `placements` | 数组,每项必填 `id`、`view`、`kind`,并按 kind 添加后文规定的 `anchor`、`target` 或 `presentation`;不要把它们写入视图定义 | | `navigation` | 单个对象,必填 `title`、`view`,引用自己的无资源视图 | +| `agent` | API 3;指令、工具和可选的 enabledWhen,见 [Agent 与设置](agent-settings.md) | +| `settings` | API 3;声明 modules/groups/items,由宿主渲染并保存,见 [Agent 与设置](agent-settings.md) | 插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 @@ -58,6 +60,9 @@ | permissions 字段 | 能力 | | --- | --- | +| `agent: true` | 追加 Agent 指令和可调用工具 | +| `models: true` | 工具调用期间请求已配置模型,凭据留在宿主 | +| `tasks: "read" / "title"` | 读取当前任务标题元数据;title 额外允许安全改名 | | `windowEffects: true` | 锚点装饰、窗口效果与无内容的对话输入活动 | | `controls: ["model.reasoning"]` | 提供可由用户选用的思考等级控件 | | `notifications: true` | 宿主 `context.notifications.show({title,body})` | @@ -157,6 +162,8 @@ API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操 ## 页面、宿主与设置 +事件可通过作用域内的 `context.events.on` 统一订阅,支持命名空间和通配符,旧 `onMessage/onWorkbenchChange` 等入口继续可用;事件名与生命周期见 [作用域事件](events.md)。 + 完整类型见同目录 `api.d.ts`。每个视图是独立沙箱。`render` 可异步,前台加载须在 10 秒内完成;耗时内容先绘制加载状态,再异步填充。加载失败会结束该视图并提供重启入口,其他视图与宿主继续运行。没有 `mount`、全局 `lexora` 或 Vue 运行时。 宿主入口示意: diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 53f09e4a..88c1b211 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -5,8 +5,8 @@ import type { BuddySessionExtensionServices } from './agent/extensions/createBud import type { ReusableBuddySession } from './agent/sessions/ReusableBuddySession' import type { AutomationClock } from './automations/AutomationScheduleEvaluator' import type { BuddyRuntime } from './BuddyRuntime' - import type { RunEventLogPort } from './events/RunEventPorts' + import type { BuddyServiceRpcServer } from './rpc/BuddyServiceRpcServer' import type { BuddyServiceErrorCode } from './rpc/runtimeRequest' import { mkdir } from 'node:fs/promises' @@ -14,16 +14,29 @@ import { join } from 'node:path' import process from 'node:process' import { currentPlatform } from '../../platform/currentPlatform' import { resolveWindowsPowerShell } from '../../platform/windows/powerShell' +import { artifactsChanged } from '../../shared/artifacts/artifactApi' import { automationNotifications } from '../../shared/automation/automationApi' +import { changesChanged } from '../../shared/changes/changeApi' +import { connectorNotifications } from '../../shared/connectors/connectorApi' import { contextPanelRpc, contextPanelStateSchema } from '../../shared/context-panel/contextPanel' +import { composerResourcesChanged } from '../../shared/conversation/composerApi' +import { CONVERSATION_CHANGED } from '../../shared/conversation/conversationApi' +import { extensionAgentRpc } from '../../shared/extensions/extensionAgent' import { ServiceHost } from '../../shared/lifecycle/ServiceHost' +import { ServiceLifecycleSource } from '../../shared/lifecycle/ServiceLifecycleSource' +import { webSettingsChanged } from '../../shared/network/webApi' +import { notificationsChanged } from '../../shared/notifications/notificationApi' import { ApplicationEvents as EventPublisher } from '../../shared/observability/ApplicationEvents' +import { observeLifecycleDiagnostics } from '../../shared/observability/lifecycleDiagnostics' import { openExternalResultSchema } from '../../shared/runtime/credentialProtocol' +import { spaceChanged } from '../../shared/spaces/spaceApi' import { PiEventBridge } from './agent/events/PiEventBridge' import { BuddyAgentRunner } from './agent/execution/BuddyAgentRunner' import { BuddyRunExecutionPlanner } from './agent/execution/BuddyRunExecutionPlanner' import { BuddyTurnLauncher } from './agent/execution/BuddyTurnLauncher' import { PiTurnExecutor } from './agent/execution/PiTurnExecutor' +import { observeSessionResourceDiagnostics } from './agent/resources/observeSessionResourceDiagnostics' +import { SessionResourceReconciler } from './agent/resources/SessionResourceReconciler' import { bindRuntimePreferences } from './agent/sessions/bindRuntimePreferences' import { BuddySessionBlueprintService } from './agent/sessions/BuddySessionBlueprintService' import { BuddySessionFactory } from './agent/sessions/BuddySessionFactory' @@ -47,6 +60,7 @@ import { AutomationOccurrenceLifecycleService } from './automations/AutomationOc import { systemAutomationClock } from './automations/AutomationScheduleEvaluator' import { AutomationScheduler } from './automations/AutomationScheduler' import { AutomationService } from './automations/AutomationService' +import { AutomationTurnService } from './automations/AutomationTurnService' import { registerAutomationRpc } from './automations/registerAutomationRpc' import { resolveAutomationModelSelection } from './automations/resolveAutomationModelSelection' import { BrowserHostClient } from './browser/BrowserHostClient' @@ -58,42 +72,65 @@ import { ChatInputValidationService } from './chat/ChatInputValidationService' import { ChatQueueService } from './chat/ChatQueueService' import { ChatTurnService } from './chat/ChatTurnService' import { ComposerDraftService } from './chat/ComposerDraftService' +import { QueueContinuation } from './chat/QueueContinuation' import { registerChatRpc } from './chat/registerChatRpc' import { registerComposerDraftRpc } from './chat/registerComposerDraftRpc' +import { TurnRequestService } from './chat/TurnRequestService' import { HostConnectorSecretStore, McpConnectorService, } from './connectors/mcp/McpConnectorService' +import { McpSessionResourceConsumer } from './connectors/mcp/McpSessionResourceConsumer' +import { observeMcpDiagnostics, observeMcpNotifications } from './connectors/mcp/observeMcpEvents' import { registerMcpConnectorRpc } from './connectors/mcp/registerMcpConnectorRpc' import { registerContextPanelRpc } from './context-panel/registerContextPanelRpc' import { ContextUsageSnapshotService } from './context/ContextUsageSnapshotService' import { registerContextRpc } from './context/registerContextRpc' import { ConversationLifecycleService } from './conversations/ConversationLifecycleService' +import { ConversationMetadataService } from './conversations/ConversationMetadataService' +import { createExtensionTaskCapabilities } from './conversations/extensionTaskCapabilities' import { registerConversationRpc } from './conversations/registerConversationRpc' import { registerConversationTreeRpc } from './conversations/registerConversationTreeRpc' import { registerTaskMarkRpc } from './conversations/registerTaskMarkRpc' +import { TaskAttentionProjection } from './conversations/TaskAttentionProjection' +import { TaskMarkService } from './conversations/TaskMarkService' import { createBuddyCapabilityFactory } from './createBuddyCapabilityFactory' import { DirectoryGrantService } from './directories/DirectoryGrantService' +import { observeRunDiagnostics } from './events/observeRunDiagnostics' +import { observeImageDiagnostics } from './images/ImageOperationLifecycle' import { ImageTransformService } from './images/ImageTransformService' import { OpenAiImageGenerationService } from './images/OpenAiImageGenerationService' import { AttentionNotificationService } from './notifications/AttentionNotificationService' +import { NotificationProjection } from './notifications/NotificationProjection' import { registerNotificationRpc } from './notifications/registerNotificationRpc' +import { observePetActionDiagnostics } from './pet/observePetActionDiagnostics' +import { PetActionService } from './pet/PetActionService' +import { ExtensionAgentRuntime } from './plugins/ExtensionAgentRuntime' +import { observeExtensionAgentDiagnostics } from './plugins/observeExtensionAgentDiagnostics' +import { PluginAuthoringService } from './plugins/PluginAuthoringService' import { createProviderService } from './providers/createProviderService' -import { registerProviderRpc } from './providers/registerProviderRpc' +import { createExtensionModelCapabilities } from './providers/extensionModelCapabilities' +import { ProviderDependents } from './providers/ProviderDependents' +import { registerProviderRpc } from './providers/registerProviderRpc' import { resolveInteractiveModelSelection } from './providers/resolveInteractiveModelSelection' import { BuddyServiceError } from './rpc/runtimeRequest' import { registerRunRpc } from './runs/registerRunRpc' +import { RunContinuityService } from './runs/RunContinuityService' import { RunLifecycleService } from './runs/RunLifecycleService' import { RunRecoveryService } from './runs/RunRecoveryService' +import { observeSandboxClient } from './sandbox/observeSandboxClient' import { ShellSandboxClient } from './sandbox/ShellSandboxClient' +import { observeSkillDiagnostics, observeSkillNotifications } from './skills/observeSkillEvents' import { registerSkillServiceRpc, SkillService, } from './skills/SkillService' import { registerSpaceFileRpc } from './spaces/registerSpaceFileRpc' import { registerSpaceRpc } from './spaces/registerSpaceRpc' +import { SpaceDependents } from './spaces/SpaceDependents' import { matchesSpaceExecutionContext } from './spaces/spaceExecutionContext' +import { SpaceFileService } from './spaces/SpaceFileService' import { SpaceService } from './spaces/SpaceService' import { createApprovalRepository } from './storage/approvalRepository' import { createArtifactRepository } from './storage/artifactRepository' @@ -106,6 +143,7 @@ import { createCommandRequestRepository } from './storage/commandRequestReposito import { createComposerDraftRepository } from './storage/composerDraftRepository' import { createComposerResourceRepository } from './storage/composerResourceRepository' import { createConnectorRepository } from './storage/connectorRepository' +import { createConversationDeletionRepository } from './storage/conversationDeletionRepository' import { createConversationDirectoryGrantRepository } from './storage/conversationDirectoryGrantRepository' import { createConversationRepository } from './storage/conversationRepository' import { createConversationTreeRepository } from './storage/conversationTreeRepository' @@ -122,15 +160,18 @@ import { createUsageRepository } from './storage/usageRepository' import { createWorkspaceRepository } from './storage/workspaceRepository' import { registerUsageRpc } from './usage/registerUsageRpc' import { UsageService } from './usage/UsageService' +import { observeWebDiagnostics, observeWebHostDiagnostics } from './web/observeWebDiagnostics' import { WebCapabilityService } from './web/WebCapabilityService' import { WebHostClient } from './web/WebHostClient' import { registerWebSettingsRpc, WebSettingsService } from './web/WebSettingsService' import { normalizeComposerWorkspace } from './workspace/normalizeComposerWorkspace' import { registerWorkspaceStateRpc } from './workspace/registerWorkspaceStateRpc' +import { WorkspaceStateService } from './workspace/WorkspaceStateService' export interface StartBuddyServiceOptions { record?: ApplicationDiagnosticReporter events?: ApplicationEvents + lifecycle?: ServiceLifecycleSource automationClock?: AutomationClock buddyHome: string builtinSkillsDirectories?: readonly string[] @@ -150,8 +191,9 @@ export async function startBuddyService( const events = options.events ?? new EventPublisher() if (options.record) events.subscribe(options.record) - const host = new ServiceHost(events) const record = events.publish + const host = new ServiceHost(options.lifecycle ?? new ServiceLifecycleSource(() => record({ event: 'observer.failed', component: 'runtime.lifecycle', level: 'warn' }))) + const stopLifecycleDiagnostics = options.lifecycle ? () => {} : observeLifecycleDiagnostics(host.lifecycle, events) try { const paths = new BuddyDataPaths(options.buddyHome) const agentDirectory = join(options.buddyHome, 'agent') @@ -168,24 +210,112 @@ export async function startBuddyService( const spacesRepository = createSpaceRepository(options.database) const conversations = createConversationRepository(options.database) const runs = createRunRepository(options.database) + await host.start('runtime.run_diagnostics', ({ defer }) => { + const subscription = observeRunDiagnostics(options.eventLog, runs, record) + defer(() => subscription.dispose()) + }) const conversationDirectoryGrants = createConversationDirectoryGrantRepository(options.database) + const taskMarkRepository = createTaskMarkRepository(options.database) + const taskMarks = await host.start('runtime.task_marks', ({ defer }) => { + const service = new TaskMarkService(taskMarkRepository, () => record({ event: 'observer.failed', component: 'runtime.task_marks', level: 'warn' })) + const diagnostics = service.onDidCommit(event => record({ + event: `task.mark.${event.kind}`, + level: 'info', + operationId: event.commitId, + ...('markId' in event ? { markId: event.markId } : {}), + ...(event.kind === 'attention' ? { conversationId: event.conversationId, revision: event.state.readRevision } : {}), + ...(event.kind === 'deleted' ? { count: event.conversationIds.length } : {}), + })) + defer(() => { + service.dispose() + diagnostics.dispose() + }) + return service + }) + const taskAttention = await host.start('runtime.task_attention', ({ defer }) => { + const projection = new TaskAttentionProjection({ + marks: taskMarks, + eventLog: options.eventLog, + repository: taskMarkRepository, + runs, + onError: () => record({ event: 'task.attention.reconciliation_failed', level: 'warn' }), + }) + const diagnostics = projection.onDidChange(event => record({ event: 'task.attention.changed', level: 'info', revision: event.revision, count: event.conversationIds.length })) + defer(() => { + projection.dispose() + diagnostics.dispose() + }) + return projection + }) const runInputs = createRunInputRepository(options.database) const approvalsRepository = createApprovalRepository(options.database) const usageRepository = createUsageRepository(options.database) const workspace = createWorkspaceRepository(options.database) - const turnRequests = createTurnRequestRepository(options.database) + const turnRequests = await host.start('runtime.turn_requests', ({ defer }) => { + const service = new TurnRequestService(createTurnRequestRepository(options.database), () => record({ event: 'observer.failed', component: 'runtime.turn_requests', level: 'warn' })) + const notifications = service.onDidCommit((event) => { + const conversation = conversations.findById(event.conversationId) + if (conversation) + options.rpc.notify(CONVERSATION_CHANGED, conversation) + }) + const diagnostics = service.onDidCommit((event) => { + for (const fact of event.facts) + record({ event: fact.kind, level: 'info', operationId: event.commitId, runId: event.runId, conversationId: event.conversationId, branchId: event.branchId, requestId: event.requestId, ...(fact.kind === 'draft.consumed' ? { revision: fact.receipt.committedRevision } : {}), ...(fact.kind === 'attachments.bound' ? { count: fact.attachmentIds.length } : {}) }) + }) + defer(() => { + service.dispose() + notifications.dispose() + diagnostics.dispose() + }) + return service + }) const composerDrafts = createComposerDraftRepository(options.database) const commandRequests = createCommandRequestRepository(options.database) const connectorsRepository = createConnectorRepository(options.database) - const spaceService = new SpaceService(spacesRepository) + const spaceService = await host.start('runtime.spaces', ({ defer }) => { + const service = new SpaceService(spacesRepository, () => record({ event: 'space.observer.failed', level: 'warn' })) + const notices = service.onDidCommit(({ sourceId, revision, spaceId, kind }) => options.rpc.notify(spaceChanged.method, { sourceId, revision, spaceId, kind })) + const diagnostics = service.onDidCommit(event => record({ event: `space.${event.kind.replaceAll('-', '_')}`, level: 'info', spaceId: event.spaceId, revision: event.revision, count: event.directories.length })) + defer(async () => { + await service.dispose() + diagnostics.dispose() + notices.dispose() + }) + return service + }) + const spaceFiles = await host.start('runtime.space_files', ({ defer }) => { + const service = new SpaceFileService(spacesRepository) + const diagnostics = service.onDidChange(event => record({ event: `space.file.${event.kind.replaceAll('-', '_')}`, level: event.kind === 'response-denied' ? 'warn' : 'info', operationId: event.operationId, spaceId: event.spaceId, directoryId: event.directoryId, revision: event.revision })) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service + }, ['runtime.spaces']) let runner!: BuddyAgentRunner - const approvalService = await host.start('runtime.approvals', () => { + const approvalService = await host.start('runtime.approvals', ({ defer }) => { const service = new ApprovalService({ eventLog: options.eventLog, onExpired: async (runId) => { await runner.cancel(runId, 'AUTOMATION_APPROVAL_EXPIRED') }, repository: approvalsRepository, + onObserverError: () => record({ event: 'observer.failed', component: 'runtime.approvals', level: 'warn' }), + }) + const diagnostics = service.onDidChange(event => record({ + event: `approval.${event.kind}${'reason' in event ? `.${event.reason}` : 'scope' in event ? `.${event.scope}` : ''}`, + level: event.kind === 'cancellation.failed' || ('persistence' in event && event.persistence === 'failed') ? 'warn' : 'info', + runId: event.runId, + ...('approvalId' in event ? { operationId: event.approvalId } : 'requestId' in event ? { operationId: event.requestId } : {}), + ...('toolCallId' in event ? { toolCallId: event.toolCallId } : {}), + ...('count' in event ? { count: event.count } : {}), + ...('durationMs' in event ? { durationMs: event.durationMs } : {}), + ...('persistence' in event && event.persistence === 'failed' ? { errorCode: 'APPROVAL_PERSISTENCE_FAILED' } : {}), + ...('errorCode' in event ? { errorCode: event.errorCode } : {}), + })) + defer(async () => { + await service.dispose() + diagnostics.dispose() }) return service }) @@ -201,14 +331,33 @@ export async function startBuddyService( eventLog: options.eventLog, usage: usageService, }) - const runLifecycleService = new RunLifecycleService({ - record, - eventLog: options.eventLog, - repository: runs, + const runLifecycleService = await host.start('runtime.run_lifecycle', ({ defer }) => { + const service = new RunLifecycleService({ record, eventLog: options.eventLog, repository: runs }) + const diagnostics = service.onDidReconcile(event => record({ event: 'run.sql_reconciled', level: 'error', runId: event.runId, conversationId: event.conversationId, branchId: event.branchId, errorCode: event.errorCode ?? 'EVENT_LOG_FAILED' })) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service }) - const changeCaptureService = new ChangeCaptureService({ - paths, - repository: createChangeSetRepository(options.database), + const runContinuity = await host.start('runtime.run_continuity', ({ defer }) => { + const service = new RunContinuityService(runs, () => record({ event: 'observer.failed', component: 'runtime.run_continuity', level: 'warn' })) + const diagnostics = service.onDidCommit(event => record({ event: `run.continuity.${event.kind}`, level: 'info', operationId: event.operationId, conversationId: event.conversationId, branchId: event.branchId, revision: event.revision, count: event.runIds.length })) + defer(() => { + service.dispose() + diagnostics.dispose() + }) + return service + }) + const changeCaptureService = await host.start('runtime.change_capture', ({ defer }) => { + const service = new ChangeCaptureService({ paths, repository: createChangeSetRepository(options.database), onListenerError: () => record({ event: 'observer.failed', component: 'runtime.change_capture', level: 'warn' }) }) + service.onDidChange(event => record({ event: `changes.${event.kind.replaceAll('-', '_')}`, level: event.errorCode ? 'warn' : 'info', producerInstanceId: event.sourceId, operationId: event.operationId, conversationId: event.conversationId, runId: event.runId, toolCallId: event.toolCallId, revision: event.revision, count: event.count, errorCode: event.errorCode })) + service.onDidChange((event) => { + if (event.conversationId && !event.errorCode) + options.rpc.notify(changesChanged.method, { sourceId: event.sourceId, revision: event.revision, conversationId: event.conversationId, runId: event.runId }) + }) + defer(() => service.dispose()) + return service }) const runRecoveryService = new RunRecoveryService({ cancelPendingApprovals: () => approvalService.cancelPendingApprovals(), @@ -228,29 +377,43 @@ export async function startBuddyService( repository: runs, usage: usageService, }) - const attachmentService = new AttachmentService({ - paths, - repository: createAttachmentRepository(options.database), + const attachmentService = await host.start('runtime.attachment_storage', ({ defer }) => { + const service = new AttachmentService({ paths, repository: createAttachmentRepository(options.database) }) + service.onDidChange(event => record({ event: `attachment.${event.kind.replaceAll('-', '_')}`, level: event.kind === 'cleanup-failed' ? 'warn' : 'info', operationId: event.operationId, revision: event.revision, count: event.count })) + defer(() => service.dispose()) + return service }) const artifactsRepository = createArtifactRepository(options.database) - const artifactService = new ArtifactService({ repository: artifactsRepository }) + const artifactService = await host.start('runtime.artifact_catalogue', ({ defer }) => { + const service = new ArtifactService({ repository: artifactsRepository, onListenerError: () => record({ event: 'observer.failed', component: 'runtime.artifact_catalogue', level: 'warn' }) }) + service.onDidChange(event => record({ event: `artifact.${event.receipt.cause}.${event.kind.replaceAll('-', '_')}${event.kind === 'batch-settled' ? `.${event.receipt.outcome}.${event.receipt.stage}` : ''}`, level: event.errorCode ? 'warn' : 'info', producerInstanceId: event.sourceId, operationId: event.receipt.operationId, conversationId: event.receipt.conversationId, revision: event.revision, count: event.kind === 'file-written' ? event.receipt.written : event.receipt.artifactIds.length, errorCode: event.errorCode })) + service.onDidChange((event) => { + if (event.kind === 'catalogue-committed') + options.rpc.notify(artifactsChanged.method, { sourceId: event.sourceId, revision: event.revision, conversationId: event.receipt.conversationId }) + }) + defer(() => service.dispose()) + return service + }) await host.step('runtime.artifacts', () => reconcileLegacyArtifactOutputs({ artifacts: artifactsRepository, + catalogue: artifactService, conversations, eventLog: options.eventLog, paths, })) - const composerResourceService = new ComposerResourceService({ - artifacts: artifactService, - attachments: attachmentService, - conversationGrants: conversationDirectoryGrants, - conversations, - drafts: composerDrafts, - eventLog: options.eventLog, - paths, - repository: createComposerResourceRepository(options.database), - spaces: spacesRepository, - }) + const composerResourceService = await host.start('runtime.composer_resources', ({ defer }) => { + const service = new ComposerResourceService({ artifacts: artifactService, attachments: attachmentService, conversationGrants: conversationDirectoryGrants, conversations, drafts: composerDrafts, eventLog: options.eventLog, paths, repository: createComposerResourceRepository(options.database), spaces: spacesRepository }) + service.onDidChange(event => options.rpc.notify(composerResourcesChanged.method, { revision: event.revision, draftIds: [...new Set(event.resources.map(resource => resource.draftId))] })) + service.onDidChange(event => record({ event: 'composer.resources.committed', level: 'info', operationId: event.operationId, revision: event.revision, count: event.resources.length })) + defer(() => service.dispose()) + return service + }, ['runtime.attachment_storage']) + const workspaceStateService = await host.start('runtime.workspace_state', ({ defer }) => { + const service = new WorkspaceStateService({ repository: workspace, normalize: value => normalizeComposerWorkspace(value, { conversations, resources: composerResourceService }) }) + service.onDidChange(event => record({ event: `workspace.state.${event.kind.replaceAll('-', '_')}`, level: event.kind === 'normalization-failed' ? 'warn' : 'info', operationId: event.operationId, revision: event.revision })) + defer(() => service.dispose()) + return service + }, ['runtime.composer_resources']) await host.step('runtime.attachments', async () => { const attachmentRecovery = await attachmentService.reconcileStorage() composerResourceService.recoverInterruptedImports([ @@ -259,27 +422,115 @@ export async function startBuddyService( ]) await composerResourceService.cleanupDrafts() }) - const imageTransformService = new ImageTransformService({ artifacts: artifactService }) + const imageTransformService = await host.start('runtime.image_transform', ({ defer }) => { + const service = new ImageTransformService({ artifacts: artifactService }) + const diagnostics = observeImageDiagnostics(service, record) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service + }, ['runtime.artifact_catalogue']) const providersRepository = createProviderRepository(options.database) - const providerService = await host.start('runtime.providers', () => createProviderService({ - agentDirectory, - database: options.database, - getActiveRuns: () => runs.listIncomplete(), - peer: options.rpc, - providers: providersRepository, - record, - })) + const providerService = await host.start('runtime.providers', async ({ defer }) => { + const service = await createProviderService({ + agentDirectory, + database: options.database, + getActiveRuns: () => runs.listIncomplete(), + peer: options.rpc, + providers: providersRepository, + record, + }) + defer(() => service.dispose()) + return service + }) const executionModels = providerService.executionModels const imageGenerationGateway = new OpenAiImageGenerationService({ modelRuntime: executionModels.getRuntime(), resolveSourceProviderId: providerId => executionModels.resolveSourceProviderId(providerId), }) const sessions = await host.start('runtime.sessions', () => new BuddySessionRegistry()) - const directoryGrants = new DirectoryGrantService({ - conversationGrants: conversationDirectoryGrants, - conversations, - spaces: spaceService, + const conversationMetadata = await host.start('runtime.task_metadata', ({ defer }) => { + const service = new ConversationMetadataService({ + repository: conversations, + sessions: { invalidateConversation: id => sessions.invalidateConversationWithResult(id) }, + resolveModelSelection: selection => resolveInteractiveModelSelection(providerService, selection), + onObserverError: () => record({ event: 'task.observer_failed', level: 'warn' }), + }) + const notification = service.onDidCommit(event => options.rpc.notify(CONVERSATION_CHANGED, event.conversation)) + const diagnostics = service.onDidCommit(event => record({ + event: `task.${event.kind}.committed`, + level: 'info', + conversationId: event.conversation.id, + operationId: event.commitId, + })) + defer(async () => { + await service.dispose() + notification.dispose() + diagnostics.dispose() + }) + return service + }) + const extensionAgent = await host.start('runtime.plugins', ({ defer }) => { + const service = new ExtensionAgentRuntime({ + rpc: options.rpc, + handlers: { + ...createExtensionTaskCapabilities(conversationMetadata), + ...createExtensionModelCapabilities(executionModels, usageService), + }, + context: (scope) => { + const run = runs.findById(scope.runId) + const task = conversations.findById(scope.conversationId) + if (!run || run.conversationId !== scope.conversationId || run.status !== 'running' || !task || task.deletedAt !== null) + throw new Error('EXTENSION_TASK_UNAVAILABLE') + return { ...scope, model: { providerId: run.provider, modelId: run.model } } + }, + onObserverError: () => record({ event: 'plugins.observer_failed', level: 'warn' }), + }) + const binding = service.bind() + const diagnostics = observeExtensionAgentDiagnostics(service, record) + defer(async () => { + binding() + try { + await service.dispose() + } + finally { + diagnostics.dispose() + } + }) + defer(options.rpc.onNotification((method) => { + if (method === extensionAgentRpc.changed) + void sessions.invalidateMode('interactive').catch(() => record({ event: 'plugins.sessions_invalidation.failed', level: 'warn', errorCode: 'EXTENSION_INVALIDATION_FAILED' })) + })) + return service }) + const pluginBuilder = await host.start('runtime.plugin_authoring', ({ defer }) => { + const service = new PluginAuthoringService(options.rpc, () => record({ event: 'plugins.authoring.observer_failed', level: 'warn' })) + const diagnostics = service.onDidChange(event => record({ event: `plugins.authoring.${event.kind.replaceAll('-', '_')}`, level: event.errorCode ? 'warn' : 'info', extensionId: event.extensionId, operationId: event.operationId, conversationId: event.conversationId, runId: event.runId, count: event.bytes, ...(event.errorCode ? { errorCode: event.errorCode } : {}) })) + defer(async () => { + try { + await service.dispose() + } + finally { + diagnostics.dispose() + } + }) + return service + }) + const directoryGrants = await host.start('runtime.directory_grants', ({ defer }) => { + const service = new DirectoryGrantService({ + conversationGrants: conversationDirectoryGrants, + conversations, + spaces: spaceService, + onListenerError: () => record({ event: 'directory.observer.failed', level: 'warn' }), + }) + const diagnostics = service.onDidCommit(event => record({ event: 'directory.conversation.committed', level: 'info', conversationId: event.conversationId, directoryId: event.grantId, revision: event.revision, count: event.revokedGrantIds.length })) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service + }, ['runtime.spaces']) const connectorService = await host.start('runtime.connectors', ({ defer }) => { const service = new McpConnectorService({ connectors: connectorsRepository, @@ -288,53 +539,141 @@ export async function startBuddyService( if (!result.ok) throw new Error('MCP authorization page is unavailable') }, - invalidateSessions: () => sessions.invalidateAll(), - notify: (event) => { - record({ event: event.type, level: event.code ? 'warn' : 'info', component: 'runtime.connectors', connectorId: event.connectorId, errorCode: event.code }) - options.rpc.notify(event.type, event) - }, + onListenerError: () => record({ event: 'connectors.observer.failed', level: 'warn', errorCode: 'MCP_OBSERVER_FAILED' }), secrets: new HostConnectorSecretStore(options.rpc), }) - defer(() => service.close()) - service.start() + const diagnostics = observeMcpDiagnostics(service, record) + const notifications = observeMcpNotifications(service, event => options.rpc.notify(connectorNotifications.changed.method, event)) + defer(async () => { + try { + await service.close() + } + finally { + diagnostics.dispose() + notifications.dispose() + } + }) return service }) - const skillService = await host.start('runtime.skills', async ({ defer }) => { - const service = new SkillService({ + const { skillService, sessionResources } = await host.start('runtime.skills', async ({ defer }) => { + const skillService = new SkillService({ agentDirectory, builtinSkillsDirectories: options.builtinSkillsDirectories, spaces: spacesRepository, repository: createSkillRepository(options.database), paths, - changed: async (spaceId) => { - await (spaceId ? sessions.invalidateSpace(spaceId) : sessions.invalidateAll()) - options.rpc.notify('skills.changed', { spaceId }) - }, + onListenerError: () => record({ event: 'skills.observer.failed', level: 'warn', errorCode: 'SKILL_OBSERVER_FAILED' }), }) - defer(() => service.dispose()) - await service.initialize() - return service + const sessionResources = new SessionResourceReconciler({ + skills: skillService, + sessions, + onListenerError: () => record({ event: 'sessions.resources.observer_failed', level: 'warn', errorCode: 'SESSION_RESOURCE_OBSERVER_FAILED' }), + }) + const resourceDiagnostics = observeSessionResourceDiagnostics(sessionResources, record) + const skillDiagnostics = observeSkillDiagnostics(skillService, record) + const notifications = observeSkillNotifications(skillService, event => options.rpc.notify('skills.changed', event)) + defer(async () => { + try { + try { + await sessionResources.dispose() + } + finally { + await skillService.dispose() + } + } + finally { + resourceDiagnostics.dispose() + skillDiagnostics.dispose() + notifications.dispose() + } + }) + await skillService.initialize() + return { skillService, sessionResources } + }) + await host.start('runtime.connector-resources', ({ defer }) => { + const consumer = new McpSessionResourceConsumer({ service: connectorService, sessions, resources: sessionResources, report: record }) + defer(() => consumer.dispose()) + connectorService.start() + return consumer }) const browserHost = new BrowserHostClient(options.rpc) - const webSettings = new WebSettingsService(workspace, options.rpc) + const webSettings = await host.start('runtime.web_settings', ({ defer }) => { + const service = new WebSettingsService(workspace, options.rpc) + const notifications = service.onDidChange(change => options.rpc.notify(webSettingsChanged.method, { operationId: change.operationId, revision: change.revision })) + const diagnostics = service.onDidChange(change => record({ event: `web.settings.${change.kind.replaceAll('-', '_')}`, level: change.kind === 'credential-failed' ? 'warn' : 'info', operationId: change.operationId, revision: change.revision })) + defer(async () => { + await service.dispose() + notifications.dispose() + diagnostics.dispose() + }) + return service + }) + const webHost = await host.start('runtime.web_host', ({ defer }) => { + const client = new WebHostClient(options.rpc) + const diagnostics = observeWebHostDiagnostics(client, record) + defer(async () => { + await client.dispose() + diagnostics.dispose() + }) + return client + }) const webService = await host.start('runtime.web', ({ defer }) => { const service = new WebCapabilityService({ - host: new WebHostClient(options.rpc), + host: webHost, models: executionModels.getRuntime(), paths, settings: webSettings, }) - defer(() => service.dispose()) + const diagnostics = observeWebDiagnostics(service, record) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) return service }) const automationClock = options.automationClock ?? systemAutomationClock const automationRepositories = createAutomationRepositories(options.database) - const automationTurns = createAutomationTurnRepository(options.database) - const automationService = new AutomationService({ - clock: automationClock, - repositories: automationRepositories, + const automationTurns = await host.start('runtime.automation_turns', ({ defer }) => { + const service = new AutomationTurnService(createAutomationTurnRepository(options.database), () => record({ event: 'automation.turn_observer_failed', level: 'warn' })) + const notifications = service.onDidCommit((event) => { + for (const fact of event.facts) { + if (fact.kind !== 'task.created') + continue + const conversation = conversations.findById(fact.conversationId) + if (conversation) + options.rpc.notify(CONVERSATION_CHANGED, conversation) + } + }) + const diagnostics = service.onDidCommit((event) => { + for (const fact of event.facts) { + record({ event: `automation.${fact.kind}`, level: 'info', operationId: event.operationId, revision: event.revision, automationId: fact.automationId, ...('occurrenceId' in fact ? { occurrenceId: fact.occurrenceId } : {}), ...('runId' in fact ? { runId: fact.runId ?? undefined } : {}), ...('conversationId' in fact ? { conversationId: fact.conversationId ?? undefined } : {}) }) + } + }) + defer(() => { + service.dispose() + notifications.dispose() + diagnostics.dispose() + }) + return service + }) + const automationService = await host.start('runtime.automation_definitions', ({ defer }) => { + const service = new AutomationService({ + clock: automationClock, + repositories: automationRepositories, + onObserverError: () => record({ event: 'automation.observer_failed', level: 'warn' }), + }) + const diagnostics = service.onDidCommit((event) => { + for (const fact of event.facts) { + record({ event: `automation.${fact.kind}`, level: 'info', operationId: event.operationId, revision: event.revision, automationId: fact.automationId, ...('occurrenceId' in fact ? { occurrenceId: fact.occurrenceId } : {}), ...('errorCode' in fact && fact.errorCode ? { errorCode: fact.errorCode } : {}) }) + } + }) + defer(() => { + service.dispose() + diagnostics.dispose() + }) + return service }) - const notificationService = await host.start('runtime.notifications', () => { + const notificationService = await host.start('runtime.notifications', ({ defer }) => { const service = new AttentionNotificationService({ attention: createNotificationAttentionRepository(options.database), listAutomationRuns: () => automationService.listHistory({ limit: 100 }).items.flatMap( @@ -357,16 +696,31 @@ export async function startBuddyService( ), listModels: () => providersRepository.models.list(), }) + const changed = service.onDidCommit(event => options.rpc.notify(notificationsChanged.method, { revision: event.revision })) + const diagnostics = service.onDidCommit(event => record({ event: `notifications.${event.reason}`, level: 'info', operationId: event.operationId, revision: event.revision, count: event.changes.length })) + const projection = new NotificationProjection({ service, providers: providerService, automations: automationService, runs: options.eventLog, lifecycle: runLifecycleService, record }) + defer(async () => { + await projection.dispose() + service.dispose() + changed.dispose() + diagnostics.dispose() + }) return service }) let automationScheduler: AutomationScheduler | null = null - const automationChanges = new AutomationChangeCoordinator({ - notify: (automationId) => { - record({ event: 'automation.changed', level: 'info', automationId }) - options.rpc.notify(automationNotifications.changed.method, { automationId }) - }, - service: automationService, - wakeScheduler: () => automationScheduler?.wake(), + let automationDispatcher: AutomationDispatcher | null = null + let stopExecution: () => Promise = async () => {} + const automationChanges = await host.start('runtime.automation_changes', ({ defer }) => { + const consumer = new AutomationChangeCoordinator({ + notify: automationId => options.rpc.notify(automationNotifications.changed.method, { automationId }), + service: automationService, + turns: automationTurns, + runChanges: { eventLog: options.eventLog, lifecycle: runLifecycleService, runs }, + wakeScheduler: () => automationScheduler?.wake(), + onError: () => record({ event: 'automation.projection.degraded', level: 'warn' }), + }) + defer(() => consumer.dispose()) + return consumer }) automationChanges.reconcileDependencies({ isPinnedModelAvailable(providerId, modelId) { @@ -379,21 +733,52 @@ export async function startBuddyService( return Boolean(space && space.revokedAt === null) }, }) + await host.start('runtime.provider_dependencies', ({ defer }) => { + const consumer = new ProviderDependents({ source: providerService, sessions, resources: sessionResources, automations: automationChanges, record }) + defer(() => consumer.dispose()) + return consumer + }, ['runtime.providers', 'runtime.sessions']) + await host.start('runtime.space_dependencies', ({ defer }) => { + const consumer = new SpaceDependents({ source: spaceService, grants: directoryGrants, sessions, resources: sessionResources, automations: automationChanges, record }) + defer(() => consumer.dispose()) + return consumer + }, ['runtime.spaces', 'runtime.directory_grants', 'runtime.sessions']) let chatQueueService: ChatQueueService | undefined + const petActions = await host.start('runtime.pet_actions', ({ defer }) => { + const service = new PetActionService({ eventSink: event => options.eventLog.append(event), peer: options.rpc }) + const diagnostics = observePetActionDiagnostics(service, record) + defer(async () => { + try { + await service.dispose() + } + finally { + diagnostics.dispose() + } + }) + return service + }) const sessionExtensionServices: BuddySessionExtensionServices = { followUp: async (runId, signal) => { await chatQueueService?.followUp(runId, signal) }, prepareForRun: signal => connectorService.prepareForRun(signal), shellSandbox: await host.start('runtime.shell_sandbox', ({ defer }) => { const sandbox = new ShellSandboxClient(options.rpc) - defer(() => sandbox.dispose()) + const diagnostics = observeSandboxClient(sandbox, record) + defer(async () => { + await sandbox.dispose() + diagnostics.dispose() + }) return sandbox }), approvalService, attachmentService, changeCaptureService, directoryGrants, + recordPermissions: record, createCapabilities: createBuddyCapabilityFactory(currentPlatform, { + record, pluginAuthoring: options.rpc, + pluginBuilder, + pluginCapabilities: context => extensionAgent.capabilities(context), artifactService, attachmentService, automationService, @@ -409,12 +794,8 @@ export async function startBuddyService( connectorService, imageGenerationGateway, imageTransformService, - onAutomationChanged: automationId => automationChanges.publish(automationId), webService, - }, { - eventSink: event => options.eventLog.append(event), - peer: options.rpc, - }), + }, petActions), } const sessionBlueprints = new BuddySessionBlueprintService({ conversationGrants: conversationDirectoryGrants, @@ -430,7 +811,36 @@ export async function startBuddyService( runInputs, runs, }) - const conversationTree = new BuddyConversationTree({ conversationsDirectory: paths.conversationsDirectory, conversations, repository: createConversationTreeRepository(options.database), runs, recovery: sessionRecovery }) + const conversationTree = await host.start('runtime.conversation_tree', ({ defer }) => { + const service = new BuddyConversationTree({ + conversationsDirectory: paths.conversationsDirectory, + conversations, + repository: createConversationTreeRepository(options.database), + runs, + recovery: sessionRecovery, + onObserverError: () => record({ event: 'tree.observer_failed', level: 'warn' }), + }) + const commits = service.onDidCommit(event => record({ + event: event.kind === 'checkpoint.committed' + ? `tree.checkpoint.${event.position}.${event.recovery?.source ?? 'execution'}.committed` + : event.kind === 'binding.committed' ? `tree.binding.${event.reason}` : `tree.${event.kind}`, + level: 'info', + operationId: event.operationId, + conversationId: event.conversationId, + generation: event.treeId, + revision: event.revision, + ...('runId' in event ? { runId: event.runId, branchId: event.branchId } : {}), + ...(event.kind === 'entries.imported' ? { count: event.entryCount } : {}), + ...(event.kind === 'checkpoint.committed' && event.recovery?.missingAttachmentCount !== undefined ? { count: event.recovery.missingAttachmentCount } : {}), + })) + const failures = service.onDidFail(event => record({ event: `tree.${event.stage}.failed`, level: 'warn', operationId: event.operationId, conversationId: event.conversationId, errorCode: event.errorCode })) + defer(async () => { + await service.dispose() + commits.dispose() + failures.dispose() + }) + return service + }) const sessionFactory = await host.start('runtime.session_factory', () => { const service = new BuddySessionFactory({ bindPreferences: apply => bindRuntimePreferences(options.rpc, apply), @@ -448,7 +858,7 @@ export async function startBuddyService( const piTurnExecutor = new PiTurnExecutor({ eventLog: options.eventLog, piEvents: piEventBridge, - runs, + continuity: runContinuity, sessionFactory: input => sessionFactory.create(input), sessions, }) @@ -456,29 +866,88 @@ export async function startBuddyService( const service = new BuddyAgentRunner({ executor: piTurnExecutor, lifecycle: runLifecycleService, - onRunSettled: (runId) => { - approvalService.clearRunAuthorizations(runId) - chatQueueService?.onRunSettled(runId) - }, + releaseRunResources: runId => approvalService.clearRunAuthorizations(runId), + onObserverError: () => record({ event: 'execution.observer_failed', level: 'error' }), sessions, }) + const settledDiagnostics = service.onDidSettle(settled => record({ + event: 'execution.settled', + level: settled.cleanup === 'degraded' ? 'warn' : 'info', + runId: settled.runId, + conversationId: settled.conversationId, + operationId: settled.executionId, + ...(settled.cleanup === 'degraded' ? { errorCode: 'EXECUTION_CLEANUP_DEGRADED' } : {}), + })) + let stopped: Promise | undefined + stopExecution = () => stopped ??= (async () => { + const scanning = automationScheduler?.dispose() + automationDispatcher?.stop() + const errors: unknown[] = [] + await scanning?.catch(error => errors.push(error)) + await service.dispose().catch(error => errors.push(error)) + await automationDispatcher?.dispose().catch(error => errors.push(error)) + await automationScheduler?.settle().catch(error => errors.push(error)) + if (errors.length) + throw new AggregateError(errors, 'Automation and execution cleanup failed') + })() defer(async () => { - await service.dispose() - await automationScheduler?.settle() + try { + await stopExecution() + } + finally { + settledDiagnostics.dispose() + } }) return service }) - const conversationLifecycle = new ConversationLifecycleService({ - conversations, - directoryGrants: conversationDirectoryGrants, - runner, - sessions, + const conversationLifecycle = await host.start('runtime.task_deletion', ({ defer }) => { + const service = new ConversationLifecycleService({ + conversations, + deletion: createConversationDeletionRepository(options.database), + runner, + cancelQueuedRuns: async (conversationId) => { + for (const run of runs.listIncomplete()) { + if (run.conversationId === conversationId && run.status === 'queued') + await runLifecycleService.finalize({ runId: run.id, status: 'cancelled', errorCode: 'RUN_CANCELLED', completedAt: new Date().toISOString() }) + } + }, + sessions: { invalidateConversation: id => sessions.invalidateConversationWithResult(id) }, + onObserverError: () => record({ event: 'task.deletion_observer_failed', level: 'warn' }), + }) + const notification = service.onDidCommit((event) => { + const conversation = conversations.findById(event.conversationId) + if (conversation) + options.rpc.notify(CONVERSATION_CHANGED, conversation) + }) + const deletionDiagnostic = service.onDidCommit((event) => { + if (event.tombstoned) + record({ event: 'task.tombstone.committed', level: 'info', conversationId: event.conversationId, operationId: event.commitId }) + if (event.revokedGrantIds.length) + record({ event: 'task.grants.revoked', level: 'info', conversationId: event.conversationId, operationId: event.commitId, count: event.revokedGrantIds.length }) + }) + const cleanupDiagnostic = service.onDidCleanup(event => record({ event: `task.cleanup.${event.status}`, level: event.status === 'failed' ? 'warn' : 'info', conversationId: event.conversationId, operationId: event.operationId, ...(event.errorCode ? { errorCode: event.errorCode } : {}) })) + defer(async () => { + await service.dispose() + notification.dispose() + deletionDiagnostic.dispose() + cleanupDiagnostic.dispose() + }) + return service }) - const automationOccurrenceLifecycle = new AutomationOccurrenceLifecycleService({ - automations: automationService, - conversationLifecycle, - notifications: notificationService, - onChanged: automationId => automationChanges.publish(automationId), + taskAttention.start(conversationLifecycle, runLifecycleService) + const automationOccurrenceLifecycle = await host.start('runtime.automation_deletion', ({ defer }) => { + const service = new AutomationOccurrenceLifecycleService({ + automations: automationService, + conversationLifecycle, + notifications: notificationService, + onObserverError: () => record({ event: 'automation.deletion_observer_failed', level: 'warn' }), + }) + const diagnostics = service.onDidCleanup(event => record({ event: `automation.cleanup.${event.status}`, level: event.status === 'failed' ? 'warn' : 'info', automationId: event.automationId, occurrenceId: event.occurrenceId, operationId: event.operationId, errorCode: event.errorCode })) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service }) const executionPlanner = new BuddyRunExecutionPlanner({ attachments: attachmentService, @@ -495,47 +964,102 @@ export async function startBuddyService( planner: executionPlanner, runner, }) - const chatCommandService = new ChatCommandService({ - commands: commandRequests, - conversationLifecycle, - conversations, - drafts: composerDrafts, - spaces: spacesRepository, - runs, - turnLauncher, + const chatCommandService = await host.start('runtime.chat_commands', ({ defer }) => { + const service = new ChatCommandService({ + commands: commandRequests, + conversationLifecycle, + conversations, + drafts: composerDrafts, + spaces: spacesRepository, + runs, + turnLauncher, + onObserverError: () => record({ event: 'observer.failed', component: 'runtime.chat_commands', level: 'warn' }), + }) + const diagnostics = service.onDidCommit((event) => { + for (const fact of event.facts) { + record({ + event: fact.kind, + level: 'info', + operationId: event.commitId, + requestId: event.requestId, + conversationId: event.conversationId, + branchId: event.branchId, + ...(fact.kind === 'run.queued' ? { runId: fact.runId } : {}), + ...(fact.kind === 'draft.consumed' ? { revision: fact.committedRevision } : {}), + }) + } + }) + defer(async () => { + await service.dispose() + diagnostics.dispose() + }) + return service + }) + const composerDraftService = await host.start('runtime.composer_drafts', ({ defer }) => { + const service = new ComposerDraftService(composerDrafts, draftId => composerResourceService.discard(draftId), () => record({ event: 'observer.failed', component: 'runtime.composer_drafts', level: 'warn' })) + const committed = service.onDidCommit(event => record({ event: `draft.${event.kind}`, level: 'info', operationId: event.operationId, revision: event.revision })) + const cleanup = service.onDidCleanup(event => record({ event: `draft.cleanup.${event.status}`, level: event.status === 'failed' ? 'warn' : 'info', operationId: event.operationId, ...(event.errorCode ? { errorCode: event.errorCode } : {}) })) + defer(async () => { + await service.dispose() + committed.dispose() + cleanup.dispose() + }) + return service }) - const composerDraftService = new ComposerDraftService(composerDrafts, draftId => composerResourceService.discard(draftId)) - const chatTurnService = new ChatTurnService({ - inputValidation: new ChatInputValidationService({ + const chatTurnService = await host.start('runtime.chat_turns', ({ defer }) => { + const service = new ChatTurnService({ + inputValidation: new ChatInputValidationService({ + attachments: attachmentService, + models: executionModels, + paths, + recovery: sessionRecovery, + runInputs, + runs, + sessions, + tree: conversationTree, + }), + composerResources: composerResourceService, + drafts: composerDrafts, attachments: attachmentService, - models: executionModels, - paths, - recovery: sessionRecovery, + conversationLifecycle, + conversations, + spaces: spacesRepository, + providers: providerService, runInputs, + runner, runs, - sessions, - tree: conversationTree, - }), - record, - composerResources: composerResourceService, - drafts: composerDrafts, - attachments: attachmentService, - conversationLifecycle, - conversations, - spaces: spacesRepository, - providers: providerService, - runInputs, - runner, - runs, - skills: skillService, - turnLauncher, - turnRequests, - }) - chatQueueService = await host.start('runtime.chat-queue', ({ defer }) => { - const service = new ChatQueueService({ queue: createChatQueueRepository(options.database), turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs }) + skills: skillService, + turnLauncher, + turnRequests, + }) defer(() => service.dispose()) return service }) + chatQueueService = await host.start('runtime.chat-queue', async ({ defer }) => { + const service = new ChatQueueService({ queue: createChatQueueRepository(options.database), turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) }) + const continuation = new QueueContinuation({ queue: service, runner, runs, eventLog: options.eventLog, record }) + const diagnostics = service.onDidChange((event) => { + const committed = event.committed + if (!committed) + return + record({ event: `queue.${event.kind}`, level: 'info', operationId: committed.commitId, conversationId: event.scope.conversationId, branchId: event.scope.branchId, ...(committed.runId ? { runId: committed.runId } : {}) }) + if (committed.draftReceipt) + record({ event: 'draft.consumed', level: 'info', operationId: committed.commitId, revision: committed.draftReceipt.committedRevision }) + if (committed.messageId) + record({ event: 'message.created', level: 'info', operationId: committed.commitId, conversationId: event.scope.conversationId, ...(committed.runId ? { runId: committed.runId } : {}) }) + if (committed.attachmentOwnership) + record({ event: `attachments.${committed.attachmentOwnership.kind}_bound`, level: 'info', operationId: committed.commitId, conversationId: event.scope.conversationId, count: committed.attachmentOwnership.attachmentIds.length, ...(committed.runId ? { runId: committed.runId } : {}) }) + }) + defer(async () => { + const stopped = continuation.dispose() + service.dispose() + await stopped + await service.drain() + diagnostics.dispose() + }) + await continuation.start() + return service + }) const runtime: BuddyRuntime = { startTurn: input => chatTurnService.start(input), } @@ -550,37 +1074,52 @@ export async function startBuddyService( runs, sessionExtensionServices, }) - const automationDispatcher = new AutomationDispatcher(automationService, new AgentTaskAutomationAction({ - automationService, - cancelRun: (runId, errorCode) => runner.cancel(runId, errorCode), - clock: automationClock, - launchTurn: runId => turnLauncher.launch(runId), - resolveModel: target => resolveAutomationModelSelection({ - defaults: providerService, - models: executionModels, - }, target), - resolveSpace: async (spaceId, executionContext) => { - const space = spacesRepository.findById(spaceId) - if (!space || space.revokedAt !== null) - return null - if (!matchesSpaceExecutionContext(space, executionContext)) - return { status: 'context_changed' } - return { executionContext, id: space.id, status: 'ready' } - }, - turns: automationTurns, - })) + const dispatcher = await host.start('runtime.automation_dispatcher', ({ defer }) => { + const service = new AutomationDispatcher(automationService, new AgentTaskAutomationAction({ + automationService, + cancelRun: (runId, errorCode) => runner.cancel(runId, errorCode), + clock: automationClock, + launchTurn: (runId, signal) => turnLauncher.launch(runId, signal), + resolveModel: target => resolveAutomationModelSelection({ + defaults: providerService, + models: executionModels, + }, target), + resolveSpace: async (spaceId, executionContext) => { + const space = spacesRepository.findById(spaceId) + if (!space || space.revokedAt !== null) + return null + if (!matchesSpaceExecutionContext(space, executionContext)) + return { status: 'context_changed' } + return { executionContext, id: space.id, status: 'ready' } + }, + turns: automationTurns, + }), () => record({ event: 'automation.dispatch_observer_failed', level: 'warn' })) + const diagnostics = service.onDidChange(event => record({ + event: `automation.dispatch.${event.kind}`, + level: event.kind === 'failed' ? 'warn' : 'info', + ...('occurrenceId' in event ? { occurrenceId: event.occurrenceId, automationId: event.automationId } : { count: event.count }), + })) + defer(async () => { + try { + await stopExecution() + } + finally { + diagnostics.dispose() + } + }) + return service + }) + automationDispatcher = dispatcher const scheduler = new AutomationScheduler({ automationService, clock: automationClock, - dispatch: async (occurrence) => { - await events.scope({ component: 'runtime.automations', automationId: occurrence.automationId, occurrenceId: occurrence.id }).operation('automation.dispatch', () => automationDispatcher.dispatch(occurrence)) - automationChanges.publishSchedulerChange(occurrence.automationId) - }, - onChanged: automationId => automationChanges.publishSchedulerChange(automationId), + dispatch: occurrence => dispatcher.dispatch(occurrence), + onObserverError: () => record({ event: 'automation.scheduler_observer_failed', level: 'warn' }), }) automationScheduler = scheduler await host.step('runtime.recovery', async () => { + await automationOccurrenceLifecycle.recoverPendingDeletions() await conversationLifecycle.recoverPendingDeletions() const count = await runRecoveryService.recoverInterruptedRuns() record({ event: 'runtime.runs_recovered', level: 'info', count }) @@ -600,8 +1139,7 @@ export async function startBuddyService( ) register( registerWorkspaceStateRpc({ - normalize: value => normalizeComposerWorkspace(value, { conversations, resources: composerResourceService }), - repository: workspace, + service: workspaceStateService, rpc: options.rpc, }), ) @@ -675,7 +1213,7 @@ export async function startBuddyService( service: contextUsageService, }), ) - register(registerTaskMarkRpc(options.rpc, createTaskMarkRepository(options.database))) + register(registerTaskMarkRpc(options.rpc, taskMarks, taskAttention)) register(registerConversationTreeRpc({ database: options.database, conversations, @@ -693,20 +1231,16 @@ export async function startBuddyService( attachments: attachmentService, changes: changeCaptureService, conversations, + metadata: conversationMetadata, deleteConversation: async (conversationId) => { const result = await automationOccurrenceLifecycle.deleteConversation(conversationId) return result.deleted }, eventLog: options.eventLog, isDeleting: conversationId => conversationLifecycle.isDeleting(conversationId), - resolveModelSelection: selection => resolveInteractiveModelSelection( - providerService, - selection, - ), rpc: options.rpc, runInputs, runs, - sessions, }), ) register( @@ -732,20 +1266,15 @@ export async function startBuddyService( ) register( registerSpaceRpc({ - automations: automationChanges, - spaces: spacesRepository, rpc: options.rpc, service: spaceService, - sessions, }), ) - register(registerSpaceFileRpc(options.rpc, spacesRepository)) + register(registerSpaceFileRpc(options.rpc, spaceFiles)) register( registerProviderRpc({ - automations: automationChanges, rpc: options.rpc, service: providerService, - sessions, }), ) register( @@ -753,10 +1282,18 @@ export async function startBuddyService( ) }) await host.start('runtime.scheduler', ({ defer }) => { - defer(() => scheduler.dispose()) + const diagnostics = scheduler.onDidChange(event => record({ event: `automation.scheduler.${event.state}`, level: event.state === 'degraded' ? 'warn' : 'info', count: event.activeCount, errorCode: event.errorCode })) + defer(async () => { + try { + await stopExecution() + } + finally { + diagnostics.dispose() + } + }) return scheduler.start() }, ['runtime.rpc', 'runtime.execution']) - return { dispose: () => host.stop(), runtime } + return { dispose: () => host.stop().finally(stopLifecycleDiagnostics), runtime } } catch (error) { try { @@ -765,6 +1302,9 @@ export async function startBuddyService( catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Runtime initialization and cleanup failed') } + finally { + stopLifecycleDiagnostics() + } throw error } } diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 10f53c87..999512bb 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -33,6 +33,7 @@ import { ApprovalService } from '../approvals/ApprovalService' import { startBuddyService } from '../BuddyService' import { createRunEventLog } from '../events/createRunEventLog' import { PermissionEngine } from '../permissions/PermissionEngine' +import { RunContinuityService } from '../runs/RunContinuityService' import { RunLifecycleService } from '../runs/RunLifecycleService' import { RunRecoveryService } from '../runs/RunRecoveryService' import { @@ -77,10 +78,10 @@ describe('buddy runtime cross-subsystem contract', () => { const eventLog = createRunEventLog({ conversationsDirectory, database, - onEvent(event) { - if (event.type === 'approval.requested') - resolveApprovalRequested() - }, + }) + eventLog.onDidCommit((event) => { + if (event.type === 'approval.requested') + resolveApprovalRequested() }) createSpaceRepository(database).create({ additionalDirectories: [], @@ -107,7 +108,7 @@ describe('buddy runtime cross-subsystem contract', () => { executor: new PiTurnExecutor({ eventLog, piEvents, - runs, + continuity: new RunContinuityService(runs), sessionFactory: async () => ({ piSessionFile: join(root, 'pi-session.jsonl'), session, @@ -416,9 +417,9 @@ describe('buddy runtime cross-subsystem contract', () => { }) for (const turn of [firstTurn, secondTurn]) { const related = diagnostics.filter(event => event.runId === turn.runId) - for (const event of ['run.queued', 'run.started', 'turn.started', 'turn.completed', 'run.completed']) + for (const event of ['run.queued', 'run.started', 'pi.turn.started', 'pi.turn.completed', 'run.completed']) expect(related).toContainEqual(expect.objectContaining({ event, conversationId: turn.conversationId, branchId: turn.branchId })) - expect(related).toContainEqual(expect.objectContaining({ event: 'turn.completed', turnId: `${turn.runId}:1` })) + expect(related).toContainEqual(expect.objectContaining({ event: 'pi.turn.completed', turnId: `${turn.runId}:1` })) } for (const content of ['Remember both historical images', 'Continue after recovery', 'test-api-key', keptBytes.toString('base64')]) expect(JSON.stringify(diagnostics)).not.toContain(content) diff --git a/apps/buddy/service/src/agent/events/PiApplicationObserver.ts b/apps/buddy/service/src/agent/events/PiApplicationObserver.ts index 716eb629..78153e94 100644 --- a/apps/buddy/service/src/agent/events/PiApplicationObserver.ts +++ b/apps/buddy/service/src/agent/events/PiApplicationObserver.ts @@ -26,7 +26,7 @@ export class PiApplicationObserver { case 'turn_start': { this.#endTurn('interrupted') this.#turn = { id: `${this.#runId}:${++this.#turnSequence}`, startedAt: performance.now() } - this.#events.publish({ event: 'turn.started', level: 'info', turnId: this.#turn.id }) + this.#events.publish({ event: 'pi.turn.started', level: 'info', turnId: this.#turn.id }) break } case 'turn_end': { @@ -38,34 +38,34 @@ export class PiApplicationObserver { } case 'tool_execution_start': { this.#tools.set(event.toolCallId, { startedAt: performance.now(), turnId: this.#turn?.id, authorized: false }) - this.#events.publish({ event: 'tool.requested', level: 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: this.#turn?.id }) + this.#events.publish({ event: 'pi.tool.requested', level: 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: this.#turn?.id }) break } case 'tool_execution_end': { const tool = this.#tools.get(event.toolCallId) if (tool?.authorized) - this.#events.publish({ event: event.isError ? 'tool.failed' : 'tool.completed', level: event.isError ? 'warn' : 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: tool.turnId, durationMs: Math.round(performance.now() - tool.startedAt) }) + this.#events.publish({ event: event.isError ? 'pi.tool.failed' : 'pi.tool.completed', level: event.isError ? 'warn' : 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: tool.turnId, durationMs: Math.round(performance.now() - tool.startedAt) }) this.#tools.delete(event.toolCallId) break } case 'auto_retry_start': this.#retryStartedAt = performance.now() - this.#events.publish({ event: 'model.retry.started', level: 'warn', attempt: event.attempt, turnId: this.#turn?.id }) + this.#events.publish({ event: 'pi.model.retry.started', level: 'warn', attempt: event.attempt, turnId: this.#turn?.id }) break case 'auto_retry_end': - this.#events.publish({ event: event.success ? 'model.retry.completed' : 'model.retry.failed', level: event.success ? 'info' : 'error', attempt: event.attempt, durationMs: elapsed(this.#retryStartedAt) }) + this.#events.publish({ event: event.success ? 'pi.model.retry.completed' : 'pi.model.retry.failed', level: event.success ? 'info' : 'error', attempt: event.attempt, durationMs: elapsed(this.#retryStartedAt) }) this.#retryStartedAt = undefined break case 'compaction_start': this.#compactionStartedAt = performance.now() - this.#events.publish({ event: 'model.compaction.started', level: 'info' }) + this.#events.publish({ event: 'pi.model.compaction.started', level: 'info' }) break case 'compaction_end': - this.#events.publish({ event: `model.compaction.${event.aborted ? 'cancelled' : event.result ? 'completed' : 'failed'}`, level: event.aborted || event.result ? 'info' : 'error', durationMs: elapsed(this.#compactionStartedAt) }) + this.#events.publish({ event: `pi.model.compaction.${event.aborted ? 'cancelled' : event.result ? 'completed' : 'failed'}`, level: event.aborted || event.result ? 'info' : 'error', durationMs: elapsed(this.#compactionStartedAt) }) this.#compactionStartedAt = undefined break case 'agent_settled': - this.#events.publish({ event: 'execution.settled', level: 'info' }) + this.#events.publish({ event: 'pi.agent.settled', level: 'info' }) break } } @@ -76,30 +76,30 @@ export class PiApplicationObserver { tool.authorized = true tool.startedAt = performance.now() } - this.#events.publish({ event: 'tool.authorized', level: 'info', toolCallId: diagnosticToolCallId(toolCallId), turnId: tool?.turnId ?? this.#turn?.id }) + this.#events.publish({ event: 'pi.tool.authorized', level: 'info', toolCallId: diagnosticToolCallId(toolCallId), turnId: tool?.turnId ?? this.#turn?.id }) } denied(toolCallId: string, errorCode: string): void { - this.#events.publish({ event: isToolFailureCode(errorCode) ? 'tool.failed' : 'tool.denied', level: 'warn', toolCallId: diagnosticToolCallId(toolCallId), turnId: this.#tools.get(toolCallId)?.turnId ?? this.#turn?.id, errorCode }) + this.#events.publish({ event: isToolFailureCode(errorCode) ? 'pi.tool.failed' : 'pi.tool.denied', level: 'warn', toolCallId: diagnosticToolCallId(toolCallId), turnId: this.#tools.get(toolCallId)?.turnId ?? this.#turn?.id, errorCode }) this.#tools.delete(toolCallId) } settle(): void { this.#endTurn('interrupted') for (const [toolCallId, tool] of this.#tools) - this.#events.publish({ event: 'tool.interrupted', level: 'warn', toolCallId: diagnosticToolCallId(toolCallId), turnId: tool.turnId, durationMs: elapsed(tool.startedAt) }) + this.#events.publish({ event: 'pi.tool.interrupted', level: 'warn', toolCallId: diagnosticToolCallId(toolCallId), turnId: tool.turnId, durationMs: elapsed(tool.startedAt) }) this.#tools.clear() if (this.#retryStartedAt !== undefined) - this.#events.publish({ event: 'model.retry.cancelled', level: 'info', durationMs: elapsed(this.#retryStartedAt) }) + this.#events.publish({ event: 'pi.model.retry.cancelled', level: 'info', durationMs: elapsed(this.#retryStartedAt) }) if (this.#compactionStartedAt !== undefined) - this.#events.publish({ event: 'model.compaction.cancelled', level: 'info', durationMs: elapsed(this.#compactionStartedAt) }) + this.#events.publish({ event: 'pi.model.compaction.cancelled', level: 'info', durationMs: elapsed(this.#compactionStartedAt) }) this.#retryStartedAt = this.#compactionStartedAt = undefined } #endTurn(status: 'completed' | 'failed' | 'cancelled' | 'interrupted'): void { if (!this.#turn) return - this.#events.publish({ event: `turn.${status}`, level: status === 'failed' ? 'error' : status === 'interrupted' ? 'warn' : 'info', turnId: this.#turn.id, durationMs: elapsed(this.#turn.startedAt) }) + this.#events.publish({ event: `pi.turn.${status}`, level: status === 'failed' ? 'error' : status === 'interrupted' ? 'warn' : 'info', turnId: this.#turn.id, durationMs: elapsed(this.#turn.startedAt) }) this.#turn = null } } diff --git a/apps/buddy/service/src/agent/events/__tests__/PiApplicationObserver.spec.ts b/apps/buddy/service/src/agent/events/__tests__/PiApplicationObserver.spec.ts index fd19a40b..425c31ec 100644 --- a/apps/buddy/service/src/agent/events/__tests__/PiApplicationObserver.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/PiApplicationObserver.spec.ts @@ -13,7 +13,7 @@ describe('pi application metadata', () => { observer.denied(toolCallId, errorCode) observer.handle({ type: 'tool_execution_end', toolCallId, toolName: 'read', result: {}, isError: true }) } - expect(records.map(record => record.event)).toEqual(['tool.requested', 'tool.failed', 'tool.requested', 'tool.denied']) + expect(records.map(record => record.event)).toEqual(['pi.tool.requested', 'pi.tool.failed', 'pi.tool.requested', 'pi.tool.denied']) expect(records[1]?.errorCode).toBe('PATH_NOT_FOUND') }) @@ -25,8 +25,8 @@ describe('pi application metadata', () => { observer.handle({ type: 'tool_execution_start', toolCallId, toolName: 'read', args: { path: 'private-path' } }) observer.authorized(toolCallId) observer.handle({ type: 'tool_execution_end', toolCallId, toolName: 'read', result: 'private-result', isError: false }) - const toolEvents = records.filter(record => record.event.startsWith('tool.')) - expect(toolEvents.map(record => record.event)).toEqual(['tool.requested', 'tool.authorized', 'tool.completed']) + const toolEvents = records.filter(record => record.event.startsWith('pi.tool.')) + expect(toolEvents.map(record => record.event)).toEqual(['pi.tool.requested', 'pi.tool.authorized', 'pi.tool.completed']) expect(new Set(toolEvents.map(record => record.toolCallId)).size).toBe(1) expect(toolEvents[0]?.toolCallId).toMatch(/^pi:[\da-f]{64}$/) expect(toolEvents.every(record => record.turnId === 'run-1:1')).toBe(true) diff --git a/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts b/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts index 9a600775..f8f26eb8 100644 --- a/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts @@ -45,12 +45,12 @@ describe('piEventBridge', () => { emit({ type: 'turn_start' }) await channel.settle() expect(recorded.map(event => [event.event, event.runId, event.turnId])).toEqual([ - ['turn.started', 'run-1', 'run-1:1'], - ['turn.completed', 'run-1', 'run-1:1'], - ['turn.started', 'run-1', 'run-1:2'], - ['turn.failed', 'run-1', 'run-1:2'], - ['turn.started', 'run-1', 'run-1:3'], - ['turn.interrupted', 'run-1', 'run-1:3'], + ['pi.turn.started', 'run-1', 'run-1:1'], + ['pi.turn.completed', 'run-1', 'run-1:1'], + ['pi.turn.started', 'run-1', 'run-1:2'], + ['pi.turn.failed', 'run-1', 'run-1:2'], + ['pi.turn.started', 'run-1', 'run-1:3'], + ['pi.turn.interrupted', 'run-1', 'run-1:3'], ]) expect(JSON.stringify(recorded)).not.toContain('fixture-private') }) diff --git a/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts b/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts index 60eaa3b0..51d3d97d 100644 --- a/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts +++ b/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts @@ -3,8 +3,20 @@ import type { RunRecord } from '../../storage/runRecord' import type { BuddyInputReferenceV1 } from '../context/BuddyInputReference' import type { BuddySessionIdentity } from '../sessions/BuddySessionBlueprint' import type { BuddyTurnHandle } from './turnTypes' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { BuddyAgentRunError } from '../../runs/runError' +export interface ExecutionSettled { + readonly conversationId: string + readonly branchId: string + readonly runId: string + readonly executionId: string + readonly cleanup: 'completed' | 'degraded' + readonly stopping: boolean +} + export interface ActiveRunSession { steer?: (prepare: () => BuddyInputReferenceV1, skills?: readonly SkillReference[]) => boolean followUp?: (prepare: () => BuddyInputReferenceV1, skills?: readonly SkillReference[]) => boolean @@ -17,6 +29,7 @@ export interface ActiveRunContext { getCancellationCode: () => string | null identity: BuddySessionIdentity requestCancellation: (errorCode: string) => void + markCleanupDegraded: () => void runId: string signal: AbortSignal } @@ -42,19 +55,41 @@ interface StartActiveRunInput { export class ActiveRunRegistry { readonly #executions = new Map() + readonly #degradedCleanup = new Set() + readonly #settled: Emitter + readonly onDidSettle: Emitter['event'] #disposed = false + constructor(onObserverError: (error: unknown) => void = () => {}) { + this.#settled = new Emitter(onObserverError) + this.onDidSettle = this.#settled.event + } + + hasActiveExecution(conversationId: string): boolean { + return [...this.#executions.values()].some(execution => execution.state.identity.conversationId === conversationId) + } + + get isStopping(): boolean { + return this.#disposed + } + + hasDegradedCleanup(conversationId: string): boolean { + return this.#degradedCleanup.has(conversationId) + } + start(input: StartActiveRunInput): BuddyTurnHandle { if (this.#disposed) throw new DOMException('Runtime is stopping', 'AbortError') if (this.#executions.has(input.runId)) throw new BuddyAgentRunError('VALIDATION_FAILED') + const executionId = randomUUID() + let cleanup: ExecutionSettled['cleanup'] = 'completed' const state: ActiveRunState = { cancellationCode: null, controller: new AbortController(), - identity: input.identity, + identity: { ...input.identity }, runId: input.runId, session: null, } @@ -63,15 +98,32 @@ export class ActiveRunRegistry { getCancellationCode: () => state.cancellationCode, identity: state.identity, requestCancellation: errorCode => this.#requestCancellation(state, errorCode), + markCleanupDegraded: () => { cleanup = 'degraded' }, runId: state.runId, signal: state.controller.signal, } const completion = Promise.resolve() .then(() => input.execute(context)) + .catch((error) => { + cleanup = 'degraded' + throw error + }) .finally(() => { const execution = this.#executions.get(state.runId) if (execution?.state === state) this.#executions.delete(state.runId) + if (cleanup === 'degraded') + this.#degradedCleanup.add(state.identity.conversationId) + else + this.#degradedCleanup.delete(state.identity.conversationId) + this.#settled.fire(copyEventSnapshot({ + conversationId: state.identity.conversationId, + branchId: state.identity.branchId, + runId: state.runId, + executionId, + cleanup, + stopping: this.#disposed, + })) }) this.#executions.set(state.runId, { completion, state }) return { completion, runId: state.runId } @@ -119,6 +171,8 @@ export class ActiveRunRegistry { ) await Promise.allSettled(executions.map(execution => execution.completion)) this.#executions.clear() + this.#degradedCleanup.clear() + this.#settled.dispose() } async #cancelAndWait( diff --git a/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts b/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts index 1d8661b1..94ffb1d6 100644 --- a/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts +++ b/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts @@ -18,22 +18,26 @@ import { ActiveRunRegistry } from './ActiveRunRegistry' export interface BuddyAgentRunnerOptions { executor: RunExecutionBackend lifecycle: Pick - onRunSettled?: (runId: string) => void + releaseRunResources?: (runId: string) => void + onObserverError?: (error: unknown) => void sessions: Pick, 'withConversationRun' | 'dispose'> } export class BuddyAgentRunner { - readonly #activeRuns = new ActiveRunRegistry() + readonly #activeRuns: ActiveRunRegistry + readonly onDidSettle: ActiveRunRegistry['onDidSettle'] readonly #executor: BuddyAgentRunnerOptions['executor'] readonly #lifecycle: BuddyAgentRunnerOptions['lifecycle'] - readonly #onRunSettled: NonNullable + readonly #releaseRunResources: NonNullable readonly #sessions: Pick, 'withConversationRun' | 'dispose'> #lastTimestamp = 0 constructor(options: BuddyAgentRunnerOptions) { this.#executor = options.executor + this.#activeRuns = new ActiveRunRegistry(options.onObserverError) + this.onDidSettle = this.#activeRuns.onDidSettle this.#lifecycle = options.lifecycle - this.#onRunSettled = options.onRunSettled ?? (() => {}) + this.#releaseRunResources = options.releaseRunResources ?? (() => {}) this.#sessions = options.sessions } @@ -61,12 +65,7 @@ export class BuddyAgentRunner { const identity = toBuddySessionIdentity(session) return this.#activeRuns.start({ - execute: execution => this.#sessions.withConversationRun( - execution.identity, - execution.runId, - execution.signal, - () => this.#executeTurn(input, execution), - ).catch(error => this.#closeExecutionFromError(execution, error)).finally(() => this.#onRunSettled(execution.runId)), + execute: execution => this.#withConversationRun(execution, () => this.#executeTurn(input, execution)), identity, runId, }) @@ -91,12 +90,7 @@ export class BuddyAgentRunner { const identity = toBuddySessionIdentity(session) return this.#activeRuns.start({ - execute: execution => this.#sessions.withConversationRun( - execution.identity, - execution.runId, - execution.signal, - () => this.#executeCompaction(input, run, execution), - ).catch(error => this.#closeExecutionFromError(execution, error)).finally(() => this.#onRunSettled(execution.runId)), + execute: execution => this.#withConversationRun(execution, () => this.#executeCompaction(input, run, execution)), identity, runId: run.id, }) @@ -106,6 +100,18 @@ export class BuddyAgentRunner { return this.#activeRuns.steer(runId, prepare, skills) } + hasActiveExecution(conversationId: string): boolean { + return this.#activeRuns.hasActiveExecution(conversationId) + } + + get isStopping(): boolean { + return this.#activeRuns.isStopping + } + + hasDegradedCleanup(conversationId: string): boolean { + return this.#activeRuns.hasDegradedCleanup(conversationId) + } + followUp(runId: string, prepare: () => BuddyInputReferenceV1, skills?: readonly SkillReference[]): boolean { return this.#activeRuns.followUp(runId, prepare, skills) } @@ -136,6 +142,38 @@ export class BuddyAgentRunner { throw new AggregateError(failures, 'Execution backend cleanup failed') } + async #withConversationRun(execution: ActiveRunContext, operation: () => Promise): Promise { + let entered = false + try { + return await this.#sessions.withConversationRun( + execution.identity, + execution.runId, + execution.signal, + () => { + entered = true + return operation().catch(error => this.#closeExecutionFromError(execution, error)) + }, + (result) => { + if (result.cleanup === 'degraded') + execution.markCleanupDegraded() + }, + ) + } + catch (error) { + if (entered) + throw error + return await this.#closeExecutionFromError(execution, error) + } + finally { + try { + this.#releaseRunResources(execution.runId) + } + catch { + execution.markCleanupDegraded() + } + } + } + async #executeTurn( input: StartBuddyTurnInput, execution: ActiveRunContext, diff --git a/apps/buddy/service/src/agent/execution/BuddyTurnLauncher.ts b/apps/buddy/service/src/agent/execution/BuddyTurnLauncher.ts index f0ec5fd4..48b89181 100644 --- a/apps/buddy/service/src/agent/execution/BuddyTurnLauncher.ts +++ b/apps/buddy/service/src/agent/execution/BuddyTurnLauncher.ts @@ -16,10 +16,12 @@ export class BuddyTurnLauncher { this.#options = options } - async launch(runId: string): Promise { + async launch(runId: string, signal?: AbortSignal): Promise { let plan: Awaited> try { + signal?.throwIfAborted() plan = await this.#options.planner.resolve(runId) + signal?.throwIfAborted() } catch (error) { const failed = await this.#options.lifecycle.failBeforeStart(runId, error) diff --git a/apps/buddy/service/src/agent/execution/PiTurnExecutor.ts b/apps/buddy/service/src/agent/execution/PiTurnExecutor.ts index 081a083f..71325ed4 100644 --- a/apps/buddy/service/src/agent/execution/PiTurnExecutor.ts +++ b/apps/buddy/service/src/agent/execution/PiTurnExecutor.ts @@ -1,6 +1,6 @@ import type { RunEventWriter } from '../../events/RunEventPorts' +import type { RunContinuityService } from '../../runs/RunContinuityService' import type { RunRecord } from '../../storage/runRecord' -import type { RunRepository } from '../../storage/runRepository' import type { PiEventBridge, PiEventBridgeSettlement, @@ -25,14 +25,14 @@ export const AUTOMATION_SESSION_STARTUP_TIMEOUT_MS = 60_000 type PiTurnSessions = Pick< BuddySessionRegistry, - 'getOrCreate' | 'invalidateSession' + 'getOrCreate' | 'invalidateSession' | 'acknowledgeRecovery' > export interface PiTurnExecutorOptions { automationSessionStartupTimeoutMs?: number eventLog: RunEventWriter piEvents: Pick - runs: Pick + continuity: Pick sessionFactory: ( input: BuddySessionFactoryInput, ) => Promise> @@ -68,7 +68,7 @@ export class PiTurnExecutor implements RunExecutionBackend { readonly #automationSessionStartupTimeoutMs: number readonly #eventLog: PiTurnExecutorOptions['eventLog'] readonly #piEvents: PiTurnExecutorOptions['piEvents'] - readonly #runs: PiTurnExecutorOptions['runs'] + readonly #continuity: PiTurnExecutorOptions['continuity'] readonly #sessionFactory: PiTurnExecutorOptions['sessionFactory'] readonly #sessions: PiTurnSessions @@ -77,7 +77,7 @@ export class PiTurnExecutor implements RunExecutionBackend { ?? AUTOMATION_SESSION_STARTUP_TIMEOUT_MS this.#eventLog = options.eventLog this.#piEvents = options.piEvents - this.#runs = options.runs + this.#continuity = options.continuity this.#sessionFactory = options.sessionFactory this.#sessions = options.sessions } @@ -102,9 +102,9 @@ export class PiTurnExecutor implements RunExecutionBackend { ) : await bindingPromise signal.throwIfAborted() - if (!this.#runs.bindSession(run.id, binding.piSessionFile)) + if (!this.#continuity.bindSession(run.id, binding.piSessionFile)) throw new BuddyAgentRunError('RUN_NOT_FOUND') - await this.#recordSessionRecovery(run.id, binding) + await this.#recordSessionRecovery(run.id, identity, binding) signal.throwIfAborted() execution.onSessionActivated(binding.session) @@ -204,9 +204,9 @@ export class PiTurnExecutor implements RunExecutionBackend { }), ) signal.throwIfAborted() - if (!this.#runs.bindSession(run.id, binding.piSessionFile)) + if (!this.#continuity.bindSession(run.id, binding.piSessionFile)) throw new BuddyAgentRunError('RUN_NOT_FOUND') - await this.#recordSessionRecovery(run.id, binding) + await this.#recordSessionRecovery(run.id, identity, binding) signal.throwIfAborted() if (!binding.session.canCompact()) @@ -276,6 +276,7 @@ export class PiTurnExecutor implements RunExecutionBackend { async #recordSessionRecovery( runId: string, + identity: BuddySessionIdentity, binding: BuddySessionBinding, ): Promise { if (!binding.recoveredFromProductHistory) @@ -300,8 +301,7 @@ export class PiTurnExecutor implements RunExecutionBackend { }] : []), ]) - binding.recoveredFromProductHistory = false - binding.recoveryDegradation = undefined + this.#sessions.acknowledgeRecovery(identity, binding) } async #withAutomationSessionStartupTimeout( @@ -329,14 +329,7 @@ export class PiTurnExecutor implements RunExecutionBackend { identity: BuddySessionIdentity, runId: string, ): Promise { - const run = this.#runs.findById(runId) - if (run?.piSessionFile) { - this.#runs.clearSessionBindings( - run.conversationId, - run.branchId, - run.piSessionFile, - ) - } + this.#continuity.clearForRun(runId) await this.#sessions.invalidateSession(identity) } } diff --git a/apps/buddy/service/src/agent/execution/__tests__/ActiveRunRegistry.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/ActiveRunRegistry.spec.ts new file mode 100644 index 00000000..aa3825ab --- /dev/null +++ b/apps/buddy/service/src/agent/execution/__tests__/ActiveRunRegistry.spec.ts @@ -0,0 +1,57 @@ +import type { RunRecord } from '../../../storage/runRecord' +import type { BuddySessionIdentity } from '../../sessions/BuddySessionBlueprint' +import type { ExecutionSettled } from '../ActiveRunRegistry' +import { describe, expect, it } from 'vitest' +import { ActiveRunRegistry } from '../ActiveRunRegistry' + +const identity: BuddySessionIdentity = { + conversationId: 'conversation-1', + branchId: 'branch-1', + canonicalRoot: '/workspace', + scratchRoot: '/scratch', + approvalPolicy: 'policy', + executionProfile: 'workspace_write', + sessionMode: 'interactive', + spaceId: null, + grantRevision: 'grants-1', + resourceRevision: 'resources-1', +} + +describe('execution settlement', () => { + it('releases the slot before publishing once and isolates observers from completion', async () => { + const registry = new ActiveRunRegistry() + const gate = Promise.withResolvers() + const received: ExecutionSettled[] = [] + registry.onDidSettle(() => { + throw new Error('Observer unavailable') + }) + registry.onDidSettle((event) => { + expect(registry.hasActiveExecution(identity.conversationId)).toBe(false) + received.push(event) + }) + const handle = registry.start({ identity, runId: 'run-1', execute: () => gate.promise }) + expect(registry.hasActiveExecution(identity.conversationId)).toBe(true) + expect(received).toEqual([]) + const record = { id: 'run-1', status: 'running' } as RunRecord + gate.resolve(record) + expect(await handle.completion).toBe(record) + expect(received).toEqual([expect.objectContaining({ runId: 'run-1', cleanup: 'completed', stopping: false })]) + expect(received[0]!.executionId).toBeTruthy() + await registry.dispose() + }) + + it('reports degraded release without manufacturing a durable terminal', async () => { + const registry = new ActiveRunRegistry() + const received: ExecutionSettled[] = [] + registry.onDidSettle(event => received.push(event)) + const failure = new Error('Cleanup failed') + const handle = registry.start({ identity, runId: 'run-1', execute: async () => { + throw failure + } }) + await expect(handle.completion).rejects.toBe(failure) + expect(registry.hasActiveExecution(identity.conversationId)).toBe(false) + expect(received).toEqual([expect.objectContaining({ cleanup: 'degraded' })]) + expect(received[0]).not.toHaveProperty('status') + await registry.dispose() + }) +}) diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts index 5ed806aa..50d0733e 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts @@ -1,7 +1,6 @@ import type { AssistantMessage, ToolResultMessage, Usage } from '@earendil-works/pi-ai' import type { AgentSessionEvent, CompactionResult } from '@earendil-works/pi-coding-agent' import type { DatabaseSync } from 'node:sqlite' -import type { RunEventLogCallbacks } from '../../../events/RunEventLog' import type { BuddySessionBlueprint } from '../../sessions/BuddySessionBlueprint' import type { BuddySessionTurnContext, ReusableBuddySession } from '../../sessions/ReusableBuddySession' import type { PiTurnExecutorOptions } from '../PiTurnExecutor' @@ -12,6 +11,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { createRunEventLog } from '../../../events/createRunEventLog' import { RunEventStorageError } from '../../../events/RunEventFailure' +import { RunContinuityService } from '../../../runs/RunContinuityService' import { RunLifecycleService } from '../../../runs/RunLifecycleService' import { prepareTestCommandRequest, @@ -113,6 +113,69 @@ describe('buddyAgentRunner', () => { })]) }) + it('retains refused recovery records for retry and acknowledges them only after one durable batch', async () => { + const fixture = await createFixture() + const sessions = new BuddySessionRegistry() + const session = new IdleSession() + const runner = fixture.createRunnerFromFactory(async () => ({ + piSessionFile: join(fixture.root, 'recovered-session.jsonl'), + recoveredFromProductHistory: true, + recoveryDegradation: { missingAttachmentIds: ['attachment-missing'], recoveredImageCount: 1 }, + session, + }), sessions) + const appendBatch = fixture.eventLog.appendBatch.bind(fixture.eventLog) + let refuseRecovery = true + vi.spyOn(fixture.eventLog, 'appendBatch').mockImplementation((events) => { + if (refuseRecovery && events.some(event => event.type === 'session.recovered')) { + refuseRecovery = false + return Promise.reject(new Error('Recovery batch refused before commit')) + } + return appendBatch(events) + }) + const base = { + branchId: 'branch-1', + canonicalRoot: fixture.root, + conversationId: 'conversation-1', + cwd: fixture.root, + model: 'claude-sonnet-4-5', + spaceId: null, + prompt: 'Continue recovered context', + provider: 'anthropic', + resources: emptyResources(), + } + const first = { ...base, runId: 'run-recovery-refused' } + const second = { ...base, runId: 'run-recovery-retried' } + const third = { ...base, runId: 'run-after-recovery-acknowledged' } + const recoveryTypes = ['session.recovered', 'session.recovery.degraded'] + + try { + fixture.prepareTurn(first, 'message-recovery-refused') + await expect(runner.startTurn(withSession(first)).completion).resolves.toMatchObject({ status: 'failed' }) + expect(sessions.snapshot()).toMatchObject([{ recoveryPending: true, status: 'ready' }]) + expect(sessions.getReady(base.conversationId, base.branchId)).toBe(session) + expect((await fixture.eventLog.read(first.runId)).filter(event => recoveryTypes.includes(event.type))).toEqual([]) + + fixture.prepareTurn(second, 'message-recovery-retried') + await expect(runner.startTurn(withSession(second)).completion).resolves.toMatchObject({ status: 'completed' }) + expect(sessions.snapshot()).toMatchObject([{ recoveryPending: false, status: 'ready' }]) + const recovered = (await fixture.eventLog.read(second.runId)).filter(event => recoveryTypes.includes(event.type)) + expect(recovered.map(event => event.type)).toEqual(recoveryTypes) + expect(recovered[1]?.payload).toEqual({ missingAttachmentCount: 1, missingAttachmentIds: ['attachment-missing'], recoveredImageCount: 1, source: 'sqlite' }) + expect((await fixture.eventLog.list(second.runId)).filter(event => recoveryTypes.includes(event.type))).toEqual(recovered) + + fixture.prepareTurn(third, 'message-after-recovery-acknowledged') + await expect(runner.startTurn(withSession(third)).completion).resolves.toMatchObject({ status: 'completed' }) + expect(sessions.getReady(base.conversationId, base.branchId)).toBe(session) + expect((await fixture.eventLog.read(third.runId)).filter(event => recoveryTypes.includes(event.type))).toEqual([]) + expect(fixture.eventLog.listForRuns([first.runId, second.runId, third.runId]) + .filter(event => recoveryTypes.includes(event.type)).map(event => event.type)).toEqual(recoveryTypes) + } + finally { + await runner.dispose() + await fixture.eventLog.close() + } + }) + it('leaves a turn recoverable when durable usage projection fails', async () => { const fixture = await createFixture() const target = join(fixture.root, 'article-without-usage.md') @@ -208,7 +271,7 @@ describe('buddyAgentRunner', () => { it('keeps a completed run terminal when post-commit notification fails', async () => { const fixture = await createFixture({ - onEvent: () => { + onCommit: () => { throw new Error('runtime peer closed') }, }) @@ -1490,7 +1553,7 @@ function emptyResources() { } } -async function createFixture(options: Pick = {}) { +async function createFixture(options: { onCommit?: () => void } = {}) { const root = await realpath(await mkdtemp(join(tmpdir(), 'lexora-buddy-runner-'))) directories.push(root) await mkdir(root, { recursive: true }) @@ -1498,7 +1561,9 @@ async function createFixture(options: Pick = {} databases.push(database) const conversationsDirectory = join(root, 'conversations') const eventsDirectory = join(conversationsDirectory, 'conversation-1', 'events') - const eventLog = createRunEventLog({ conversationsDirectory, database, ...options }) + const eventLog = createRunEventLog({ conversationsDirectory, database }) + if (options.onCommit) + eventLog.onDidCommit(options.onCommit) const conversations = createConversationRepository(database) const runs = createRunRepository(database) const usageRepository = createUsageRepository(database) @@ -1524,7 +1589,7 @@ async function createFixture(options: Pick = {} ...executorOptions, eventLog, piEvents, - runs, + continuity: new RunContinuityService(runs), sessionFactory, sessions, }), diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts index 37438103..11e9a1e6 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts @@ -183,6 +183,27 @@ describe('buddyTurnLauncher', () => { expect((await fixture.eventLog.read('run-1')).map(event => event.type)) .toEqual(['run.failed']) }) + + it('cancels an accepted queued run when its launch scope stops during planning', async () => { + const fixture = await createFixture() + fixture.prepareTurn({ spaceId: null }) + const gate = Promise.withResolvers() + const controller = new AbortController() + fixture.resolveInputReferences.mockImplementationOnce(async () => { + await gate.promise + return { images: [], documents: [] } + }) + const launcher = fixture.createLauncher({ startTurn: () => { + throw new Error('Stopped scope must not launch') + } }) + const launch = launcher.launch('run-1', controller.signal) + await vi.waitFor(() => expect(fixture.resolveInputReferences).toHaveBeenCalled()) + controller.abort() + gate.resolve() + const handle = await launch + await expect(handle.completion).resolves.toMatchObject({ status: 'cancelled', errorCode: 'RUN_CANCELLED' }) + expect((await fixture.eventLog.read('run-1')).map(event => event.type)).toEqual(['run.cancelled']) + }) }) async function createFixture(options: { modelInput?: readonly ('text' | 'image')[], selectedSkill?: SkillReference } = {}) { diff --git a/apps/buddy/service/src/agent/extensions/BuddyCapability.ts b/apps/buddy/service/src/agent/extensions/BuddyCapability.ts index 4fe43e03..45d4dcbd 100644 --- a/apps/buddy/service/src/agent/extensions/BuddyCapability.ts +++ b/apps/buddy/service/src/agent/extensions/BuddyCapability.ts @@ -6,7 +6,15 @@ import type { DirectoryGrant } from '../../directories/resolveGrantedPath' import type { BuddyInProcessExtension } from './BuddyInProcessExtension' import type { BuddyToolDisclosurePolicy } from './discovery/toolDiscoveryContract' +export interface BuddyCapabilityResourceRevision { + readonly source: 'connector' + readonly id: string + readonly revision: string +} + export interface BuddyCapability { + dispose?: () => void | Promise + resourceRevisions?: readonly BuddyCapabilityResourceRevision[] extension: BuddyInProcessExtension classify: (event: ToolCallEvent, signal: AbortSignal) => BuddyToolClassificationResult | null | Promise diff --git a/apps/buddy/service/src/agent/extensions/__tests__/browserSessionExtensions.spec.ts b/apps/buddy/service/src/agent/extensions/__tests__/browserSessionExtensions.spec.ts index 43f8669a..0aa39a0e 100644 --- a/apps/buddy/service/src/agent/extensions/__tests__/browserSessionExtensions.spec.ts +++ b/apps/buddy/service/src/agent/extensions/__tests__/browserSessionExtensions.spec.ts @@ -1,6 +1,7 @@ import type { ApprovalRequestResult } from '../../../approvals/ApprovalService' import type { BrowserCapabilityHost } from '../../../browser/BrowserCapabilityService' import type { BuddyCapabilityServices } from '../../../createBuddyCapabilityFactory' +import type { BuddyCapabilityContext } from '../BuddyCapability' import type { BuddySessionExtensionServices } from '../createBuddySessionExtensions' import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveBuddyPlatform } from '../../../../../shared/platform' import { createBuddyCapabilityFactory } from '../../../createBuddyCapabilityFactory' +import { PetActionService } from '../../../pet/PetActionService' import { createBuddySessionExtensions } from '../createBuddySessionExtensions' const directories: string[] = [] @@ -20,6 +22,65 @@ afterEach(async () => { }) describe('browser session extensions', () => { + it('keeps capabilities available after the creating run is cancelled and stops them before session cleanup finishes', async () => { + const root = await createTemporaryDirectory() + const firstRun = new AbortController() + const release = Promise.withResolvers() + const services = createCompositionServices(createUnavailableBrowserHost()) + let capabilityContext!: BuddyCapabilityContext + let cleaned = false + services.createCapabilities = async (context) => { + capabilityContext = context + return [{ + extension: { name: 'lexora-lifetime-fixture', factory() {} }, + classify: () => null, + dispose: async () => { + await release.promise + cleaned = true + }, + }] + } + const extensions = await createBuddySessionExtensions({ canonicalRoot: root, conversationId: 'conversation-1', approvalPolicy: 'policy', executionProfile: 'workspace_write', grants: [], services, sessionMode: 'interactive', signal: firstRun.signal, spaceId: null }) + firstRun.abort() + extensions.runContext.current = { runId: 'run-next', signal: new AbortController().signal, flushProjectedEvents: async () => {}, onToolExecutionAuthorized: async () => {} } + expect(capabilityContext.signal.aborted).toBe(false) + expect(capabilityContext.getRunId()).toBe('run-next') + const stopping = extensions.dispose() + expect(capabilityContext.signal.aborted).toBe(true) + expect(cleaned).toBe(false) + release.resolve() + await stopping + expect(cleaned).toBe(true) + }) + + it('cancels capability construction and disposes a late accepted capability before rejecting startup', async () => { + const root = await createTemporaryDirectory() + const run = new AbortController() + const entered = Promise.withResolvers() + const ready = Promise.withResolvers() + const services = createCompositionServices(createUnavailableBrowserHost()) + let disposed = false + services.createCapabilities = async (context) => { + entered.resolve(context.signal) + await ready.promise + return [{ + extension: { name: 'lexora-late-fixture', factory() {} }, + classify: () => null, + dispose: () => { + disposed = true + }, + }] + } + const preparing = createBuddySessionExtensions({ canonicalRoot: root, conversationId: 'conversation-1', approvalPolicy: 'policy', executionProfile: 'workspace_write', grants: [], services, sessionMode: 'interactive', signal: run.signal, spaceId: null }) + const rejected = expect(preparing).rejects.toMatchObject({ name: 'AbortError' }) + const scope = await entered.promise + run.abort() + expect(scope.aborted).toBe(true) + ready.resolve() + await rejected + expect(disposed).toBe(true) + }) + it.each(['interactive', 'automation_background'] as const)('keeps automation availability tied to session mode: %s', async (sessionMode) => { const root = await createTemporaryDirectory() const extensions = await createBuddySessionExtensions({ @@ -38,6 +99,34 @@ describe('browser session extensions', () => { expect(names).toEqual(expect.arrayContaining(['lexora-tool-policy', 'lexora-change-capture', 'lexora-image-generation', 'lexora-image-transform'])) }) + it('closes admission immediately and drains every capability even if one cleanup fails', async () => { + const root = await createTemporaryDirectory() + const release = Promise.withResolvers() + let finalFact = false + const services = createCompositionServices(createUnavailableBrowserHost()) + services.createCapabilities = async () => [ + { extension: { name: 'lexora-failed-cleanup', factory() {} }, classify: () => null, dispose() { throw new Error('fixture-cleanup-failed') } }, + { extension: { name: 'lexora-draining', factory() {} }, classify: () => null, async dispose() { + await release.promise + + finalFact = true + } }, + ] + const extensions = await createBuddySessionExtensions({ canonicalRoot: root, conversationId: 'conversation-1', approvalPolicy: 'policy', executionProfile: 'workspace_write', grants: [], services, sessionMode: 'interactive', signal: new AbortController().signal, spaceId: null }) + let settled = false + const stopping = extensions.dispose() + const rejected = expect(stopping).rejects.toThrow('SESSION_CAPABILITY_CLEANUP_FAILED').then(() => { + settled = true + }) + expect(extensions.toolCapabilities.snapshot.status).toBe('disposed') + expect(extensions.dispose()).toBe(stopping) + await Promise.resolve() + expect(settled).toBe(false) + release.resolve() + await rejected + expect(finalFact).toBe(true) + }) + it('validates local HTML against the session grant before authorization', async () => { const root = await createTemporaryDirectory() const outside = await createTemporaryDirectory() @@ -151,10 +240,11 @@ function createCompositionServices( return { classifications: new Map(), diagnostics: [], tools: [], available: () => false } }, }, - } as unknown as BuddyCapabilityServices, { + } as unknown as BuddyCapabilityServices, new PetActionService({ peer: { request: async () => { throw new Error('No host action is expected during extensions') } }, - }), + })), directoryGrants: { + assertCurrent: () => {}, grant: async (input: { owner: { id: string, kind: string }, root: string }) => ({ changed: true, coveredGrantIds: [], diff --git a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts index 4879598c..21a030bf 100644 --- a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts +++ b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts @@ -1,17 +1,21 @@ import type { BuddyServiceTier } from '../../../../shared/conversation/modelSelection' +import type { ApplicationDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' import type { BuddyApprovalPolicy } from '../../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile' import type { BuddySessionMode } from '../../../../shared/permissions/sessionMode' import type { ApprovalService } from '../../approvals/ApprovalService' import type { AttachmentService } from '../../attachments/AttachmentService' import type { ChangeCaptureService } from '../../changes/ChangeCaptureService' -import type { DirectoryGrantMutation, DirectoryGrantService } from '../../directories/DirectoryGrantService' +import type { DirectoryGrantService } from '../../directories/DirectoryGrantService' import type { DirectoryGrant } from '../../directories/resolveGrantedPath' import type { ShellSandboxClient } from '../../sandbox/ShellSandboxClient' import type { BuddyInputReferenceStore } from '../context/BuddyInputReference' -import type { BuddyCapabilityFactory } from './BuddyCapability' +import type { BuddyCapability, BuddyCapabilityFactory, BuddyCapabilityResourceRevision } from './BuddyCapability' import type { BuddyExtensionRunContextStore } from './BuddyExtensionRunContext' import type { BuddyInProcessExtension } from './BuddyInProcessExtension' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' +import { SessionDirectoryGrants } from '../../directories/SessionDirectoryGrants' +import { observeSessionPermissions } from '../../permissions/observeSessionPermissions' import { ToolAuthorizationService } from '../../permissions/ToolAuthorizationService' import { ToolExecutionPermissions } from '../../permissions/ToolExecutionPermissions' import { SandboxDirectoryPermissions } from '../../sandbox/SandboxDirectoryPermissions' @@ -19,6 +23,8 @@ import { createShellCapability } from '../../sandbox/shellCapability' import { resolveShellExecution } from '../../sandbox/shellExecution' import { createChangeCaptureExtension } from './changeCaptureExtension' import { createChatQueueExtension } from './chatQueueExtension' +import { observeSessionTools } from './discovery/observeSessionTools' +import { SessionToolCapabilities } from './discovery/SessionToolCapabilities' import { createToolDiscoveryCapability } from './discovery/toolDiscoveryExtension' import { createInputReferenceExtension } from './inputReferenceExtension' import { createToolPolicyExtension } from './toolPolicyExtension' @@ -30,7 +36,8 @@ export interface BuddySessionExtensionServices { attachmentService: Pick changeCaptureService: Pick createCapabilities: BuddyCapabilityFactory - directoryGrants: Pick + directoryGrants: Pick + recordPermissions?: ApplicationDiagnosticReporter shellSandbox?: Pick } @@ -48,6 +55,9 @@ export interface CreateBuddySessionExtensionsOptions { } export interface BuddySessionExtensions { + toolCapabilities: Pick + dispose: () => Promise + resourceRevisions: readonly BuddyCapabilityResourceRevision[] getServiceTier: () => BuddyServiceTier | null inputReferences: BuddyInputReferenceStore inProcessExtensions: readonly BuddyInProcessExtension[] @@ -58,100 +68,136 @@ export async function createBuddySessionExtensions( options: CreateBuddySessionExtensionsOptions, ): Promise { const { services } = options - const grants = [...options.grants] + const creationSignal = options.signal + const lifetime = new AbortController() + const cancelCreation = () => lifetime.abort(creationSignal.reason) + const grants = new SessionDirectoryGrants(options.grants) const sandboxDirectories = new SandboxDirectoryPermissions() const runContext: BuddyExtensionRunContextStore = { current: null } const inputReferences: BuddyInputReferenceStore = { pending: null } const executionPermissions = new ToolExecutionPermissions() - const authorization = new ToolAuthorizationService({ - applySandboxDirectory: (run, grant) => sandboxDirectories.grant(run, grant), - applyGrant: async proposal => applyGrantToSession(grants, await services.directoryGrants.grant(proposal)), - approvalAvailable: options.sessionMode === 'interactive', - approvalPolicy: options.approvalPolicy, - approvalService: services.approvalService, - cwd: options.canonicalRoot, - executionPermissions, - executionProfile: options.executionProfile, - getGrants: () => grants, - owner: options.spaceId - ? { id: options.spaceId, kind: 'space' } - : { id: options.conversationId, kind: 'conversation' }, - }) - const capabilities = [...await services.createCapabilities({ - conversationId: options.conversationId, - executionProfile: options.executionProfile, - cwd: options.canonicalRoot, - getRunId: () => runContext.current?.runId, - getExecutionGrants: toolCallId => [...grants, ...executionPermissions.get(runContext.current, toolCallId)], - grants, - sessionMode: options.sessionMode, - signal: options.signal, - })] - const execution = resolveShellExecution(options.executionProfile) - if (execution.boundary === 'sandbox') { - capabilities.push(createShellCapability({ - cwd: options.canonicalRoot, - execution, - getGrants: () => grants, - resourceReadRoots: options.skillReadRoots ?? [], - getRunContext: () => runContext.current, - authorization, - sandbox: services.shellSandbox, - directoryPermissions: sandboxDirectories, - })) + const diagnostics = observeSessionPermissions({ grants: grants.onDidChange, sandbox: sandboxDirectories.onDidChange, tools: executionPermissions.onDidChange }, options.conversationId, services.recordPermissions) + let toolCapabilities: SessionToolCapabilities | undefined + let stopToolDiagnostics: { dispose: () => void } | undefined + const capabilities: BuddyCapability[] = [] + let disposing: Promise | undefined + const dispose = (): Promise => { + if (disposing) + return disposing + const completion = Promise.withResolvers() + disposing = completion.promise + creationSignal.removeEventListener('abort', cancelCreation) + lifetime.abort() + toolCapabilities?.dispose() + void Promise.allSettled(capabilities.map(async capability => capability.dispose?.())).then((results) => { + stopToolDiagnostics?.dispose() + grants.dispose() + sandboxDirectories.dispose() + executionPermissions.dispose() + diagnostics.dispose() + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) + throw new AggregateError(failures.map(result => result.reason), 'SESSION_CAPABILITY_CLEANUP_FAILED') + }).then(completion.resolve, completion.reject) + return disposing } - options.signal.throwIfAborted() - const discovery = createToolDiscoveryCapability(capabilities.flatMap(capability => capability.disclosure ? [capability.disclosure] : [])) - const sessionCapabilities = [...capabilities, discovery] - const inProcessExtensions: BuddyInProcessExtension[] = [ - createInputReferenceExtension(inputReferences), - ...options.sessionMode === 'interactive' && services.followUp - ? [createChatQueueExtension({ getRunContext: () => runContext.current, followUp: services.followUp })] - : [], - ...sessionCapabilities.map(capability => capability.extension), - createToolPolicyExtension({ - authorization, - classifyTool: async (event, run) => { - for (const capability of sessionCapabilities) { - const classification = await capability.classify(event, run.signal) - if (classification) - return classification - } - return {} + try { + creationSignal.addEventListener('abort', cancelCreation, { once: true }) + creationSignal.throwIfAborted() + const authorization = new ToolAuthorizationService({ + applySandboxDirectory: (run, grant) => sandboxDirectories.grant(run, grant), + applyGrant: async (proposal) => { + const mutation = await services.directoryGrants.grant(proposal) + services.directoryGrants.assertCurrent(proposal.owner, mutation.grant.id) + grants.apply(mutation) }, - getRunContext: () => runContext.current, - }), - createChangeCaptureExtension({ + approvalAvailable: options.sessionMode === 'interactive', + approvalPolicy: options.approvalPolicy, + approvalService: services.approvalService, + cwd: options.canonicalRoot, + executionPermissions, + executionProfile: options.executionProfile, + getGrants: () => grants.snapshot, + owner: options.spaceId + ? { id: options.spaceId, kind: 'space' } + : { id: options.conversationId, kind: 'conversation' }, + }) + capabilities.push(...await services.createCapabilities({ conversationId: options.conversationId, + executionProfile: options.executionProfile, cwd: options.canonicalRoot, - getRunContext: () => runContext.current, - grants, - getWorkspaceGrants: () => [...grants, ...sandboxDirectories.getWriteGrants(runContext.current)], - service: services.changeCaptureService, - workspaceMutationTools: capabilities.flatMap(capability => capability.workspaceMutationTools ?? []), - }), - ] + getRunId: () => runContext.current?.runId, + getExecutionGrants: toolCallId => [...grants.snapshot, ...executionPermissions.get(runContext.current, toolCallId)], + get grants() { return grants.snapshot }, + sessionMode: options.sessionMode, + signal: lifetime.signal, + })) + const execution = resolveShellExecution(options.executionProfile) + if (execution.boundary === 'sandbox') { + capabilities.push(createShellCapability({ + cwd: options.canonicalRoot, + execution, + getGrants: () => grants.snapshot, + resourceReadRoots: options.skillReadRoots ?? [], + getRunContext: () => runContext.current, + authorization, + sandbox: services.shellSandbox, + directoryPermissions: sandboxDirectories, + })) + } + creationSignal.throwIfAborted() + const resourceRevisions = copyEventSnapshot(capabilities.flatMap(capability => capability.resourceRevisions ?? [])) + toolCapabilities = new SessionToolCapabilities(resourceRevisions) + stopToolDiagnostics = observeSessionTools(toolCapabilities, options.conversationId, () => runContext.current?.runId, services.recordPermissions) + const discovery = createToolDiscoveryCapability(capabilities.flatMap(capability => capability.disclosure ? [capability.disclosure] : []), toolCapabilities) + const sessionCapabilities = [...capabilities, discovery] + const inProcessExtensions: BuddyInProcessExtension[] = [ + createInputReferenceExtension(inputReferences), + ...options.sessionMode === 'interactive' && services.followUp + ? [createChatQueueExtension({ getRunContext: () => runContext.current, followUp: services.followUp })] + : [], + ...sessionCapabilities.map(capability => capability.extension), + createToolPolicyExtension({ + authorization, + classifyTool: async (event, run) => { + for (const capability of sessionCapabilities) { + const classification = await capability.classify(event, run.signal) + if (classification) + return classification + } + return {} + }, + getRunContext: () => runContext.current, + }), + createChangeCaptureExtension({ + conversationId: options.conversationId, + cwd: options.canonicalRoot, + getRunContext: () => runContext.current, + get grants() { return grants.snapshot }, + getWorkspaceGrants: () => [...grants.snapshot, ...sandboxDirectories.getWriteGrants(runContext.current)], + service: services.changeCaptureService, + workspaceMutationTools: capabilities.flatMap(capability => capability.workspaceMutationTools ?? []), + }), + ] - return { - getServiceTier: () => runContext.current?.serviceTier ?? null, - inputReferences, - inProcessExtensions, - runContext, + return { + dispose, + resourceRevisions, + toolCapabilities, + getServiceTier: () => runContext.current?.serviceTier ?? null, + inputReferences, + inProcessExtensions, + runContext, + } } -} - -function applyGrantToSession(grants: DirectoryGrant[], mutation: DirectoryGrantMutation): void { - const covered = new Set(mutation.coveredGrantIds) - for (let index = grants.length - 1; index >= 0; index -= 1) { - if (covered.has(grants[index]!.grantId)) - grants.splice(index, 1) + catch (error) { + try { + await dispose() + } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'SESSION_EXTENSION_INITIALIZATION_FAILED') } + throw error + } + finally { + creationSignal.removeEventListener('abort', cancelCreation) } - if (grants.some(grant => grant.grantId === mutation.grant.id)) - return - grants.push({ - canonicalRoot: mutation.grant.canonicalRoot, - grantId: mutation.grant.id, - kind: 'granted', - root: mutation.grant.root, - }) } diff --git a/apps/buddy/service/src/agent/extensions/discovery/SessionToolCapabilities.ts b/apps/buddy/service/src/agent/extensions/discovery/SessionToolCapabilities.ts new file mode 100644 index 00000000..fc499e05 --- /dev/null +++ b/apps/buddy/service/src/agent/extensions/discovery/SessionToolCapabilities.ts @@ -0,0 +1,112 @@ +import type { Api, Message, Model } from '@earendil-works/pi-ai' +import type { ToolInfo } from '@earendil-works/pi-coding-agent' +import type { BuddyCapabilityResourceRevision } from '../BuddyCapability' +import type { BuddyToolDisclosurePolicy, ToolSearchInput } from './toolDiscoveryContract' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot' +import { ToolDisclosure } from './ToolDisclosure' + +export type ActiveToolReason = 'initial' | 'discovery' | 'request' | 'context' | 'model' | 'tree' | 'compact' | 'resume' +export interface SessionToolSnapshot { + readonly instanceId: string + readonly revision: number + readonly disclosureRevision: number + readonly resources: readonly BuddyCapabilityResourceRevision[] + readonly discovered: readonly string[] + readonly active: readonly string[] + readonly model: { readonly provider: string, readonly id: string } | null + readonly status: 'initializing' | 'ready' | 'degraded' | 'disposed' +} +export interface SessionToolChange { + readonly kind: 'catalog-accepted' | 'disclosure-changed' | 'active-applied' | 'application-failed' | 'disposed' + readonly reason: ActiveToolReason | 'restore' + readonly snapshot: SessionToolSnapshot +} +export class SessionToolCapabilities { + readonly #changes = new Emitter(() => console.error('SESSION_TOOLS_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #instanceId = randomUUID() + readonly #resources: readonly BuddyCapabilityResourceRevision[] + #disclosure?: ToolDisclosure + #revision = 0 + #active: readonly string[] = Object.freeze([]) + #model: SessionToolSnapshot['model'] = null + #status: SessionToolSnapshot['status'] = 'initializing' + + constructor(resources: readonly BuddyCapabilityResourceRevision[] = []) { + this.#resources = copyEventSnapshot(resources) + } + + get snapshot(): SessionToolSnapshot { + return Object.freeze({ instanceId: this.#instanceId, revision: this.#revision, resources: this.#resources, disclosureRevision: this.#disclosure?.snapshot.revision ?? 0, discovered: this.#disclosure?.snapshot.discovered ?? Object.freeze([]), active: this.#active, model: this.#model, status: this.#status }) + } + + initialize(tools: readonly ToolInfo[], resident: readonly string[], policies: readonly BuddyToolDisclosurePolicy[]): void { + this.#assertCurrent() + this.#disclosure?.dispose() + this.#disclosure = new ToolDisclosure(tools, resident, policies) + this.#disclosure.onDidChange(event => this.#publish('disclosure-changed', event.reason)) + this.#status = 'initializing' + this.#publish('catalog-accepted', 'initial') + } + + search(input: ToolSearchInput, model: Model | undefined) { + this.#assertCurrent() + if (!this.#disclosure) + throw new Error('SESSION_TOOLS_NOT_INITIALIZED') + return this.#disclosure.search(input, model) + } + + restore(messages: readonly Message[]): void { + this.#assertCurrent() + this.#disclosure?.restore(messages) + } + + connectedTools(model: Model | undefined) { + return this.#disclosure?.connectedTools(model) ?? [] + } + + apply(model: Model | undefined, reason: ActiveToolReason, adapter: { getActiveTools: () => string[], setActiveTools: (tools: string[]) => void }): void { + this.#assertCurrent() + if (!this.#disclosure) + return + const desired = this.#disclosure.active(model) + const nextModel = model ? Object.freeze({ provider: model.provider, id: model.id }) : null + try { + if (JSON.stringify(adapter.getActiveTools()) !== JSON.stringify(desired)) + adapter.setActiveTools(desired) + const actual = Object.freeze([...adapter.getActiveTools()]) + const changed = this.#status !== 'ready' || JSON.stringify(actual) !== JSON.stringify(this.#active) || JSON.stringify(nextModel) !== JSON.stringify(this.#model) + this.#active = actual + this.#model = nextModel + this.#status = 'ready' + if (changed) + this.#publish('active-applied', reason) + } + catch (error) { + this.#status = 'degraded' + this.#publish('application-failed', reason) + throw error + } + } + + dispose(): void { + if (this.#status === 'disposed') + return + this.#disclosure?.dispose() + this.#status = 'disposed' + this.#publish('disposed', 'context') + this.#changes.dispose() + } + + #assertCurrent(): void { + if (this.#status === 'disposed') + throw new Error('SESSION_TOOLS_DISPOSED') + } + + #publish(kind: SessionToolChange['kind'], reason: SessionToolChange['reason']): void { + this.#revision++ + this.#changes.fire(Object.freeze({ kind, reason, snapshot: this.snapshot })) + } +} diff --git a/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts b/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts index 212946cc..e8590a94 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts @@ -3,21 +3,38 @@ import type { ToolInfo } from '@earendil-works/pi-coding-agent' import type { BuddyToolDisclosurePolicy, ToolSearchInput, ToolSearchResult } from './toolDiscoveryContract' import { getCurrentSystemMessage } from '@earendil-works/pi-ai' import MiniSearch from 'minisearch' +import { Emitter } from '../../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot' import { isToolSearchResult, TOOL_SEARCH_NAME } from './toolDiscoveryContract' const segmenter = new Intl.Segmenter('zh', { granularity: 'word' }) +export interface ToolDisclosureChange { + readonly revision: number + readonly reason: 'discovery' | 'restore' + readonly added: readonly string[] + readonly removed: readonly string[] + readonly discovered: readonly string[] +} + export class ToolDisclosure { + readonly #changes = new Emitter(() => console.error('TOOL_DISCLOSURE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false readonly #policies: ReadonlyMap - readonly #tools: ReadonlyMap + readonly #tools: ReadonlyMap>> readonly #index: MiniSearch readonly #resident: readonly string[] #discovered = new Set() constructor(tools: readonly ToolInfo[], resident: readonly string[], policies: readonly BuddyToolDisclosurePolicy[]) { - this.#tools = new Map(tools.map(tool => [tool.name, tool])) - this.#policies = new Map(policies.flatMap(policy => policy.toolNames.map(name => [name, policy] as const))) - this.#resident = resident.filter(name => !this.#policies.has(name)) + this.#tools = new Map(tools.map(tool => [tool.name, Object.freeze({ name: tool.name, description: tool.description })])) + this.#policies = new Map(policies.flatMap((policy) => { + const owned = copyEventSnapshot(policy) + return owned.toolNames.map(name => [name, owned] as const) + })) + this.#resident = Object.freeze([...new Set(resident.filter(name => !this.#policies.has(name)))]) this.#index = new MiniSearch({ idField: 'name', fields: ['name', 'description', 'keywords', 'fields'], @@ -32,6 +49,13 @@ export class ToolDisclosure { }))) } + get snapshot() { return Object.freeze({ revision: this.#revision, discovered: Object.freeze([...this.#discovered]) }) } + + dispose(): void { + this.#disposed = true + this.#changes.dispose() + } + active(model: Model | undefined): string[] { return [...this.#resident, ...this.#discovered].filter(name => this.#available(name, model)) } @@ -54,12 +78,13 @@ export class ToolDisclosure { : this.#index.search(query, { filter: match => available(String(match.id)) }).map(match => String(match.id)) const matches = ranked.slice(0, requested.length > 0 ? 5 : input.limit ?? 3) const previous = new Set(this.active(model)) + const discovered = new Set(this.#discovered) const tools = matches.flatMap((name) => { const tool = this.#tools.get(name) if (!tool) return [] if (!this.#resident.includes(name)) - this.#discovered.add(name) + discovered.add(name) return [{ name, description: tool.description.slice(0, 240), @@ -67,6 +92,7 @@ export class ToolDisclosure { alreadyDisclosed: previous.has(name), }] }) + this.#replace(discovered, 'discovery') return { version: 1, tools, @@ -78,7 +104,7 @@ export class ToolDisclosure { restore(messages: readonly Message[]): void { const current = getCurrentSystemMessage(messages) if (current) { - this.#discovered = new Set((current.toolsAdded ?? []).map(tool => tool.name).filter(name => this.#policies.has(name))) + this.#replace(new Set((current.toolsAdded ?? []).map(tool => tool.name).filter(name => this.#policies.has(name))), 'restore') return } const discovered = new Set() @@ -110,7 +136,18 @@ export class ToolDisclosure { discovered.add(message.toolName) } } - this.#discovered = new Set([...discovered].filter(name => this.#policies.has(name))) + this.#replace(new Set([...discovered].filter(name => this.#policies.has(name))), 'restore') + } + + #replace(next: Set, reason: ToolDisclosureChange['reason']): void { + if (this.#disposed) + throw new Error('TOOL_DISCLOSURE_DISPOSED') + const added = [...next].filter(name => !this.#discovered.has(name)) + const removed = [...this.#discovered].filter(name => !next.has(name)) + if (!added.length && !removed.length) + return + this.#discovered = next + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, reason, added, removed, discovered: [...next] })) } #available(name: string, model: Model | undefined): boolean { diff --git a/apps/buddy/service/src/agent/extensions/discovery/__tests__/SessionToolCapabilities.spec.ts b/apps/buddy/service/src/agent/extensions/discovery/__tests__/SessionToolCapabilities.spec.ts new file mode 100644 index 00000000..1da5aeb4 --- /dev/null +++ b/apps/buddy/service/src/agent/extensions/discovery/__tests__/SessionToolCapabilities.spec.ts @@ -0,0 +1,57 @@ +import type { Api, Model } from '@earendil-works/pi-ai' +import type { ToolInfo } from '@earendil-works/pi-coding-agent' +import type { SessionToolChange } from '../SessionToolCapabilities' +import { Type } from 'typebox' +import { describe, expect, it } from 'vitest' +import { SessionToolCapabilities } from '../SessionToolCapabilities' + +const tools: ToolInfo[] = ['read', 'create_image'].map(name => ({ name, description: name, parameters: Type.Object({}), sourceInfo: { source: 'extension', path: '', origin: 'top-level', scope: 'temporary' } })) +const model = { provider: 'fixture', id: 'images' } as Model +function setup() { + const state = new SessionToolCapabilities([{ source: 'connector', id: 'fixture', revision: 'catalog-1' }]) + const events: SessionToolChange[] = [] + state.onDidChange(event => events.push(event)) + state.initialize(tools, ['read'], [{ group: 'image_generation', toolNames: ['create_image'], keywords: '', available: model => model?.id === 'images' }]) + let active = ['read'] + const adapter = { getActiveTools: () => [...active], setActiveTools: (value: string[]) => { + active = [...value] + } } + state.apply(model, 'initial', adapter) + return { state, events, adapter } +} + +describe('session capability facts', () => { + it('keeps disclosure committed when Pi application fails and only reports confirmed active tools', () => { + const { state, events, adapter } = setup() + state.search({ toolNames: ['create_image'] }, model) + expect(state.snapshot.discovered).toEqual(['create_image']) + expect(state.snapshot.active).toEqual(['read']) + expect(() => state.apply(model, 'discovery', { ...adapter, setActiveTools: () => { + throw new Error('Pi rejected application') + } })).toThrow('Pi rejected') + expect(state.snapshot).toMatchObject({ status: 'degraded', active: ['read'], disclosureRevision: 1 }) + expect(events.at(-1)?.kind).toBe('application-failed') + state.apply(model, 'context', adapter) + expect(state.snapshot).toMatchObject({ status: 'ready', active: ['read', 'create_image'] }) + const revision = state.snapshot.revision + state.search({ toolNames: ['create_image'] }, model) + state.apply(model, 'context', adapter) + expect(state.snapshot.revision).toBe(revision) + expect(Object.isFrozen(events.at(-1)?.snapshot.active)).toBe(true) + }) + + it('separates model filtering from restored disclosure and rejects writes after disposal', () => { + const { state, events, adapter } = setup() + state.search({ toolNames: ['create_image'] }, model) + state.apply(model, 'discovery', adapter) + state.apply({ ...model, id: 'text' }, 'model', adapter) + expect(state.snapshot).toMatchObject({ discovered: ['create_image'], active: ['read'] }) + state.restore([]) + expect(events.at(-1)).toMatchObject({ kind: 'disclosure-changed', reason: 'restore' }) + state.apply(model, 'tree', adapter) + expect(state.snapshot).toMatchObject({ discovered: [], active: ['read'] }) + state.dispose() + expect(events.at(-1)?.kind).toBe('disposed') + expect(() => state.search({ toolNames: ['create_image'] }, model)).toThrow('SESSION_TOOLS_DISPOSED') + }) +}) diff --git a/apps/buddy/service/src/agent/extensions/discovery/observeSessionTools.ts b/apps/buddy/service/src/agent/extensions/discovery/observeSessionTools.ts new file mode 100644 index 00000000..7916e725 --- /dev/null +++ b/apps/buddy/service/src/agent/extensions/discovery/observeSessionTools.ts @@ -0,0 +1,8 @@ +import type { ApplicationDiagnosticReporter } from '../../../../../shared/diagnostics/applicationDiagnostic' +import type { SessionToolCapabilities } from './SessionToolCapabilities' +import { safeDiagnosticReporter } from '../../../../../shared/diagnostics/applicationDiagnostic' + +export function observeSessionTools(source: Pick, conversationId: string, getRunId: () => string | undefined, report?: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report ?? (() => {})) + return source.onDidChange(event => record({ event: `session.tools.${event.kind.replaceAll('-', '_')}.${event.reason}`, level: event.kind === 'application-failed' ? 'warn' : 'info', operationId: event.snapshot.instanceId, conversationId, runId: getRunId(), revision: event.snapshot.revision, count: event.kind === 'disclosure-changed' ? event.snapshot.discovered.length : event.snapshot.active.length })) +} diff --git a/apps/buddy/service/src/agent/extensions/discovery/toolDiscoveryExtension.ts b/apps/buddy/service/src/agent/extensions/discovery/toolDiscoveryExtension.ts index 3ab193b2..200a75fa 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/toolDiscoveryExtension.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/toolDiscoveryExtension.ts @@ -1,18 +1,19 @@ import type { ExtensionContext } from '@earendil-works/pi-coding-agent' import type { BuddyCapability } from '../BuddyCapability' +import type { ActiveToolReason } from './SessionToolCapabilities' import type { BuddyToolDisclosurePolicy, ToolSearchInput } from './toolDiscoveryContract' import { buildSessionContext, convertToLlm, defineTool } from '@earendil-works/pi-coding-agent' import { Check } from 'typebox/value' -import { ToolDisclosure } from './ToolDisclosure' +import { SessionToolCapabilities } from './SessionToolCapabilities' import { TOOL_SEARCH_NAME, toolSearchParameters } from './toolDiscoveryContract' -export function createToolDiscoveryCapability(policies: readonly BuddyToolDisclosurePolicy[]): BuddyCapability { +export function createToolDiscoveryCapability(policies: readonly BuddyToolDisclosurePolicy[], ownedState?: SessionToolCapabilities): BuddyCapability { return { classify: event => event.toolName === TOOL_SEARCH_NAME ? { access: 'read', paths: [] } : null, extension: { name: 'lexora-tool-discovery', factory(pi) { - let disclosure: ToolDisclosure | undefined + const state = ownedState ?? new SessionToolCapabilities() let description = '' const searchTool = defineTool({ name: TOOL_SEARCH_NAME, @@ -26,7 +27,7 @@ export function createToolDiscoveryCapability(policies: readonly BuddyToolDisclo ], async execute(_toolCallId, parameters, signal, _onUpdate, context) { signal?.throwIfAborted() - if (!Check(toolSearchParameters, parameters) || !disclosure) + if (!Check(toolSearchParameters, parameters) || state.snapshot.status === 'initializing') throw new Error('Invalid tool search request') const input = parameters as ToolSearchInput if ((input.query !== undefined) === (input.toolNames !== undefined) @@ -34,38 +35,37 @@ export function createToolDiscoveryCapability(policies: readonly BuddyToolDisclo || (input.toolNames && input.limit !== undefined)) { throw new Error('Supply query OR toolNames; limit applies only to query') } - const result = disclosure.search(input, context.model) - sync(context) + const result = state.search(input, context.model) + sync(context, 'discovery') return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result } }, }) - function sync(context: ExtensionContext) { - if (!disclosure) - return - const next = describeToolSearch(disclosure.connectedTools(context.model)) + function sync(context: ExtensionContext, reason: ActiveToolReason) { + const next = describeToolSearch(state.connectedTools(context.model)) if (description !== next) { description = next pi.registerTool({ ...searchTool, description }) } - pi.setActiveTools(disclosure.active(context.model)) + state.apply(context.model, reason, pi) } - function restore(context: ExtensionContext) { - disclosure?.restore(convertToLlm(buildSessionContext(context.sessionManager.getBranch()).messages)) - sync(context) + function restore(context: ExtensionContext, reason: ActiveToolReason) { + state.restore(convertToLlm(buildSessionContext(context.sessionManager.getBranch()).messages)) + sync(context, reason) } pi.registerTool(searchTool) pi.on('session_start', (event, context) => { - disclosure = new ToolDisclosure(pi.getAllTools(), pi.getActiveTools(), policies) + state.initialize(pi.getAllTools(), pi.getActiveTools(), policies) if (event.reason === 'resume' || event.reason === 'fork') - restore(context) + restore(context, 'resume') else - sync(context) + sync(context, 'initial') }) - pi.on('before_agent_start', (_event, context) => sync(context)) - pi.on('context', (_event, context) => sync(context)) - pi.on('model_select', (_event, context) => sync(context)) - pi.on('session_tree', (_event, context) => restore(context)) - pi.on('session_compact', (_event, context) => restore(context)) + pi.on('before_agent_start', (_event, context) => sync(context, 'request')) + pi.on('context', (_event, context) => sync(context, 'context')) + pi.on('model_select', (_event, context) => sync(context, 'model')) + pi.on('session_tree', (_event, context) => restore(context, 'tree')) + pi.on('session_shutdown', () => state.dispose()) + pi.on('session_compact', (_event, context) => restore(context, 'compact')) }, }, } diff --git a/apps/buddy/service/src/agent/resources/BuddySessionResources.ts b/apps/buddy/service/src/agent/resources/BuddySessionResources.ts index aab10747..ad4a39a8 100644 --- a/apps/buddy/service/src/agent/resources/BuddySessionResources.ts +++ b/apps/buddy/service/src/agent/resources/BuddySessionResources.ts @@ -12,6 +12,7 @@ export interface BuddySessionResources { context: BoundedContextFilesResult directoryContext: string revision: string + skillRevision?: string } export interface ResolveBuddySessionResourcesOptions { @@ -61,6 +62,7 @@ export async function resolveBuddySessionResources( context, directoryContext, revision: hash.digest('hex'), + skillRevision: skills.revision, } } diff --git a/apps/buddy/service/src/agent/resources/SessionResourceReconciler.ts b/apps/buddy/service/src/agent/resources/SessionResourceReconciler.ts new file mode 100644 index 00000000..c9dbac09 --- /dev/null +++ b/apps/buddy/service/src/agent/resources/SessionResourceReconciler.ts @@ -0,0 +1,232 @@ +import type { Event, ListenerErrorHandler } from '../../../../shared/events/Emitter' +import type { EventSubscription } from '../../../../shared/events/eventTypes' +import type { SkillService } from '../../skills/SkillService' +import type { BuddyCapabilityResourceRevision } from '../extensions/BuddyCapability' +import type { BuddySessionIdentity } from '../sessions/BuddySessionBlueprint' +import type { BuddySessionInvalidationResult, BuddySessionRegistry, BuddySessionSnapshot, DisposableBuddySession } from '../sessions/BuddySessionRegistry' +import { Emitter } from '../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' + +export interface SessionResourceScope { + readonly spaceId: string | null + readonly skillRevision: string | null + readonly status: 'current' | 'pending' | 'degraded' + readonly pending: number + readonly degraded: number +} + +export type SessionResourceChange + = | { readonly type: 'external-invalidation', readonly source: 'provider' | 'connector' | 'space' | 'directory', readonly result: BuddySessionInvalidationResult } + | { readonly type: 'desired', readonly scope: SessionResourceScope } + | { readonly type: 'invalidation', readonly scope: SessionResourceScope } + | { readonly type: 'resolution-failed', readonly scope: SessionResourceScope, readonly error: 'SESSION_RESOURCE_RESOLUTION_FAILED' } + | { readonly type: 'applied', readonly sessionId: string, readonly spaceId: string | null, readonly resourceRevision: string, readonly skillRevision: string | null, readonly capabilityRevisions: readonly BuddyCapabilityResourceRevision[] } + +export interface SessionResourceInvalidation { + readonly source: 'provider' | 'connector' | 'space' | 'directory' + readonly scope: string + readonly revision: string + readonly retry?: boolean + readonly sessionIds?: readonly string[] + readonly matches: (identity: Readonly) => boolean +} + +interface SessionResourceReconcilerOptions { + skills: Pick + sessions: BuddySessionRegistry + onListenerError?: ListenerErrorHandler +} + +export class SessionResourceReconciler { + readonly #options: SessionResourceReconcilerOptions + readonly #changes: Emitter + readonly #subscriptions: EventSubscription[] + readonly #scopes = new Map() + readonly #refreshScopes = new Set() + readonly #resolutionFailures = new Set() + readonly #external = new Map }>() + #refreshQueued = false + #tail = Promise.resolve() + #disposed = false + + constructor(options: SessionResourceReconcilerOptions) { + this.#options = options + this.#changes = new Emitter(options.onListenerError ?? (() => console.error('SESSION_RESOURCE_OBSERVER_FAILED'))) + this.#subscriptions = [ + options.skills.onDidCommitInstallation(event => this.#refresh(event.spaceId)), + options.skills.onDidChangeResources(event => this.#accept(event.spaceId, event.resourceRevision)), + options.sessions.onDidChange((event) => { + const { session } = event + if (event.type === 'ready') + this.#applied(session) + if (event.type === 'registered' || event.type === 'ready') + this.#enqueue(() => this.#reconcile(session.identity.spaceId)) + this.#updateStatus(session.identity.spaceId) + }), + ] + for (const { spaceId, resolution } of options.skills.resourceSnapshots()) + this.#accept(spaceId, resolution.revision) + for (const session of options.sessions.snapshot()) { + if (session.status === 'ready') + this.#applied(session) + if (!this.#scopes.has(session.identity.spaceId)) + this.#refresh(session.identity.spaceId) + } + } + + readonly onDidChange: Event = (listener, options) => this.#changes.event(listener, options) + + snapshot(): readonly SessionResourceScope[] { + return Object.freeze([...this.#scopes.values()]) + } + + async whenIdle(): Promise { + let pending: Promise + do { + pending = this.#tail + await pending + } while (pending !== this.#tail) + } + + resync(spaceId: string | null = null): Promise { + if (!this.#disposed) + this.#refresh(spaceId) + return this.whenIdle() + } + + reconcileInvalidation(input: SessionResourceInvalidation): Promise { + const key = `${input.source}\0${input.scope}` + const previous = this.#external.get(key) + if (previous?.revision === input.revision && !input.retry) + return previous.result + const sessionIds = input.sessionIds ? [...input.sessionIds] : undefined + const result = this.#tail.then(async () => { + if (this.#disposed || this.#external.get(key)?.result !== result) + return Object.freeze({ matched: 0, pending: 0, degraded: 0 }) + const receipt = await this.#options.sessions.invalidateMatching(input.matches, sessionIds ? { sessionIds } : undefined) + if (!this.#disposed) + this.#changes.fire(Object.freeze({ type: 'external-invalidation', source: input.source, result: receipt })) + return receipt + }) + this.#external.set(key, { revision: input.revision, result }) + this.#tail = result.then((receipt) => { + if (receipt.degraded && this.#external.get(key)?.result === result) + this.#external.delete(key) + }, () => { + if (this.#external.get(key)?.result === result) + this.#external.delete(key) + }) + return result + } + + async dispose(): Promise { + await this.#options.skills.quiesce() + await this.whenIdle() + this.#disposed = true + for (const subscription of this.#subscriptions) + subscription.dispose() + await this.whenIdle() + this.#changes.dispose() + this.#refreshScopes.clear() + this.#external.clear() + } + + #refresh(spaceId: string | null): void { + if (spaceId === null) { + this.#refreshScopes.add(null) + for (const scope of this.#scopes.keys()) + this.#refreshScopes.add(scope) + for (const session of this.#options.sessions.snapshot()) + this.#refreshScopes.add(session.identity.spaceId) + } + else { + this.#refreshScopes.add(spaceId) + } + if (this.#refreshQueued) + return + this.#refreshQueued = true + this.#enqueue(async () => { + this.#refreshQueued = false + const scopes = [...this.#refreshScopes] + this.#refreshScopes.clear() + for (const scope of scopes) { + if (this.#disposed) + return + try { + const resolution = await this.#options.skills.loadForSpace(scope) + this.#accept(scope, resolution.revision) + } + catch { + this.#resolutionFailed(scope) + } + } + }) + } + + #accept(spaceId: string | null, revision: string): void { + if (this.#disposed) + return + this.#resolutionFailures.delete(spaceId) + if (this.#scopes.get(spaceId)?.skillRevision === revision) { + this.#updateStatus(spaceId) + return + } + const scope = copyEventSnapshot({ spaceId, skillRevision: revision, ...this.#status(spaceId, revision) }) + this.#scopes.set(spaceId, scope) + this.#changes.fire(Object.freeze({ type: 'desired', scope })) + this.#enqueue(() => this.#reconcile(spaceId)) + } + + async #reconcile(spaceId: string | null): Promise { + const revision = this.#scopes.get(spaceId)?.skillRevision + if (!revision || this.#disposed) + return + await this.#options.sessions.invalidateMatching(identity => identity.spaceId === spaceId && identity.skillRevision !== revision) + if (!this.#disposed) + this.#updateStatus(spaceId) + } + + #updateStatus(spaceId: string | null): void { + const previous = this.#scopes.get(spaceId) + if (!previous || this.#disposed) + return + const status = this.#status(spaceId, previous.skillRevision) + if (previous.status === status.status && previous.pending === status.pending && previous.degraded === status.degraded) + return + const scope = copyEventSnapshot({ ...previous, ...status }) + this.#scopes.set(spaceId, scope) + this.#changes.fire(Object.freeze({ type: 'invalidation', scope })) + } + + #status(spaceId: string | null, revision: string | null): Pick { + const affected = this.#options.sessions.snapshot().filter(session => session.identity.spaceId === spaceId && session.identity.skillRevision !== revision) + const failedCleanup = affected.filter(session => session.cleanup === 'failed').length + const degraded = failedCleanup + Number(this.#resolutionFailures.has(spaceId)) + const pending = affected.length - failedCleanup + return { status: degraded ? 'degraded' : pending ? 'pending' : 'current', pending, degraded } + } + + #resolutionFailed(spaceId: string | null): void { + if (this.#disposed) + return + this.#resolutionFailures.add(spaceId) + const previous = this.#scopes.get(spaceId) + const scope = copyEventSnapshot({ spaceId, skillRevision: previous?.skillRevision ?? null, status: 'degraded' as const, pending: previous?.pending ?? 0, degraded: Math.max(1, previous?.degraded ?? 0) }) + this.#scopes.set(spaceId, scope) + this.#changes.fire(Object.freeze({ type: 'resolution-failed', scope, error: 'SESSION_RESOURCE_RESOLUTION_FAILED' })) + } + + #applied(session: BuddySessionSnapshot): void { + this.#changes.fire(Object.freeze({ type: 'applied', sessionId: session.id, spaceId: session.identity.spaceId, resourceRevision: session.identity.resourceRevision, skillRevision: session.identity.skillRevision ?? null, capabilityRevisions: session.resourceRevisions })) + } + + #enqueue(action: () => Promise): void { + this.#tail = this.#tail.then(async () => { + if (!this.#disposed) + await action() + }).catch(() => { + if (!this.#disposed) + console.error('SESSION_RESOURCE_RECONCILIATION_FAILED') + }) + } +} diff --git a/apps/buddy/service/src/agent/resources/__tests__/SessionResourceReconciler.spec.ts b/apps/buddy/service/src/agent/resources/__tests__/SessionResourceReconciler.spec.ts new file mode 100644 index 00000000..de72bd93 --- /dev/null +++ b/apps/buddy/service/src/agent/resources/__tests__/SessionResourceReconciler.spec.ts @@ -0,0 +1,122 @@ +import type { SkillEvent } from '../../../skills/skillEvents' +import type { BuddySkillResolution } from '../../../skills/SkillService' +import type { BuddySessionIdentity } from '../../sessions/BuddySessionBlueprint' +import type { SessionResourceChange } from '../SessionResourceReconciler' +import { describe, expect, it, vi } from 'vitest' +import { Emitter, filterEvent } from '../../../../../shared/events/Emitter' +import { BuddySessionRegistry } from '../../sessions/BuddySessionRegistry' +import { SessionResourceReconciler } from '../SessionResourceReconciler' + +class Skills { + quiesce = async () => {} + readonly events = new Emitter(() => {}) + readonly onDidCommitInstallation = filterEvent(this.events.event, (event): event is Extract => event.type === 'installation') + readonly onDidChangeResources = filterEvent(this.events.event, (event): event is Extract => event.type === 'resources') + readonly resolutions = new Map() + sequence = 0 + loadForSpace = async (spaceId: string | null): Promise => this.resolutions.get(spaceId)! + resourceSnapshots() { + return [...this.resolutions].map(([spaceId, resolution]) => ({ spaceId, resolution })) + } + + accept(spaceId: string | null, revision: string) { + const previousRevision = this.resolutions.get(spaceId)?.revision ?? null + this.resolutions.set(spaceId, { revision, skills: [], references: [], paths: [], readRoots: [], diagnostics: [] }) + this.events.fire({ type: 'resources', sourceId: 'source', sequence: ++this.sequence, generation: this.sequence, spaceId, resourceRevision: revision, previousRevision, skillIds: [] }) + } + + commit(spaceId: string | null) { + this.events.fire({ type: 'installation', sourceId: 'source', sequence: ++this.sequence, generation: this.sequence, spaceId, reason: 'enabled', installationIds: ['skill'], operationId: 'operation' }) + } +} + +describe('sessionResourceReconciler', () => { + it('distinguishes desired resources, deferred invalidation, actual cleanup and a newly applied session', async () => { + const skills = new Skills() + skills.accept('space-a', 'skills-1') + skills.accept('space-b', 'skills-b') + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const reconciler = new SessionResourceReconciler({ skills, sessions }) + const events: SessionResourceChange[] = [] + reconciler.onDidChange(event => events.push(event)) + const cleanup = Promise.withResolvers() + const firstIdentity = identity('space-a', 'skills-1') + await sessions.getOrCreate(firstIdentity, null, async () => ({ piSessionFile: '/sessions/a', session: { shutdown: () => cleanup.promise } })) + await sessions.getOrCreate(identity('space-b', 'skills-b'), null, async () => ({ piSessionFile: '/sessions/b', session: { shutdown: async () => { + throw new Error('Other scope must stay active') + } } })) + await reconciler.whenIdle() + const gate = Promise.withResolvers() + const run = sessions.withConversationRun(firstIdentity, 'run', undefined, () => gate.promise) + await vi.waitFor(() => expect(sessions.getActiveRun(firstIdentity)?.runId).toBe('run')) + skills.accept('space-a', 'skills-2') + await reconciler.whenIdle() + expect(reconciler.snapshot().find(scope => scope.spaceId === 'space-a')).toMatchObject({ skillRevision: 'skills-2', status: 'pending', pending: 1 }) + expect(sessions.snapshot().find(session => session.identity.spaceId === 'space-a')?.invalidationPending).toBe(true) + expect(events.filter(event => event.type === 'applied').map(event => event.skillRevision)).toEqual(['skills-1', 'skills-b']) + gate.resolve() + await vi.waitFor(() => expect(sessions.snapshot().find(session => session.identity.spaceId === 'space-a')?.cleanup).toBe('pending')) + expect(reconciler.snapshot().find(scope => scope.spaceId === 'space-a')?.status).toBe('pending') + cleanup.resolve() + await run + expect(reconciler.snapshot().find(scope => scope.spaceId === 'space-a')?.status).toBe('current') + expect(sessions.getReady('conversation-space-b', 'branch')).not.toBeNull() + await sessions.getOrCreate(identity('space-a', 'skills-2'), null, async () => ({ piSessionFile: '/sessions/a', session: { shutdown: async () => {} } })) + await reconciler.whenIdle() + expect(events.at(-1)).toMatchObject({ type: 'applied', skillRevision: 'skills-2', resourceRevision: 'resource-skills-2' }) + await reconciler.dispose() + await expect(sessions.dispose()).rejects.toThrow('Session shutdown failed') + }) + + it('retains a degraded scope after cleanup failure without claiming that a new resource revision was applied', async () => { + const skills = new Skills() + skills.accept(null, 'skills-1') + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const reconciler = new SessionResourceReconciler({ skills, sessions }) + await sessions.getOrCreate(identity(null, 'skills-1'), null, async () => ({ piSessionFile: '/sessions/a', session: { shutdown: async () => { + throw new Error('shutdown unavailable') + } } })) + const events: SessionResourceChange[] = [] + reconciler.onDidChange(event => events.push(event)) + skills.accept(null, 'skills-2') + await reconciler.whenIdle() + expect(reconciler.snapshot()[0]).toMatchObject({ status: 'degraded', pending: 0, degraded: 1 }) + expect(events.some(event => event.type === 'applied')).toBe(false) + expect(await sessions.invalidateConversationWithResult('conversation-null')).toEqual({ matched: 1, pending: 0, degraded: 1 }) + await reconciler.dispose() + await sessions.dispose() + }) + + it('drains and accepts pending discovery before removing source subscriptions', async () => { + const skills = new Skills() + skills.accept(null, 'skills-1') + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const reconciler = new SessionResourceReconciler({ skills, sessions }) + await reconciler.whenIdle() + const entered = Promise.withResolvers() + const resolution = Promise.withResolvers() + let loads = 0 + skills.loadForSpace = () => { + loads++ + entered.resolve() + return resolution.promise + } + const events: SessionResourceChange[] = [] + reconciler.onDidChange(event => events.push(event)) + skills.commit(null) + await entered.promise + const disposed = reconciler.dispose() + resolution.resolve({ revision: 'late', skills: [], references: [], paths: [], readRoots: [], diagnostics: [] }) + await disposed + skills.commit(null) + skills.accept(null, 'later') + expect(events.some(event => event.type === 'desired' && event.scope.skillRevision === 'late')).toBe(true) + expect(loads).toBe(1) + expect(reconciler.snapshot()[0]?.skillRevision).toBe('late') + await sessions.dispose() + }) +}) + +function identity(spaceId: string | null, skillRevision: string): BuddySessionIdentity { + return { approvalPolicy: 'policy', branchId: 'branch', canonicalRoot: '/workspace', conversationId: `conversation-${spaceId}`, executionProfile: 'workspace_write', grantRevision: 'grant-1', resourceRevision: `resource-${skillRevision}`, skillRevision, scratchRoot: '/scratch', sessionMode: 'interactive', spaceId } +} diff --git a/apps/buddy/service/src/agent/resources/observeSessionResourceDiagnostics.ts b/apps/buddy/service/src/agent/resources/observeSessionResourceDiagnostics.ts new file mode 100644 index 00000000..3227ca24 --- /dev/null +++ b/apps/buddy/service/src/agent/resources/observeSessionResourceDiagnostics.ts @@ -0,0 +1,25 @@ +import type { ApplicationDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' +import type { Event } from '../../../../shared/events/Emitter' +import type { SessionResourceChange } from './SessionResourceReconciler' +import { safeDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' + +export function observeSessionResourceDiagnostics(source: { onDidChange: Event }, report: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report) + return source.onDidChange((event) => { + if (event.type === 'external-invalidation') { + record({ event: `sessions.resources.${event.source}_invalidation`, component: 'runtime.sessions', level: event.result.degraded ? 'warn' : 'debug', count: event.result.matched }) + return + } + if (event.type === 'applied') { + record({ event: 'sessions.resources.applied', component: 'runtime.sessions', level: 'debug', sessionId: event.sessionId, generation: event.resourceRevision }) + return + } + record({ + event: event.type === 'resolution-failed' ? 'sessions.resources.resolution_failed' : `sessions.resources.${event.type}`, + component: 'runtime.sessions', + level: event.scope.status === 'degraded' ? 'warn' : 'debug', + count: event.scope.pending + event.scope.degraded, + ...(event.type === 'resolution-failed' ? { errorCode: event.error } : {}), + }) + }) +} diff --git a/apps/buddy/service/src/agent/sessions/BuddySessionBlueprint.ts b/apps/buddy/service/src/agent/sessions/BuddySessionBlueprint.ts index 8e91bbd9..a917e1ca 100644 --- a/apps/buddy/service/src/agent/sessions/BuddySessionBlueprint.ts +++ b/apps/buddy/service/src/agent/sessions/BuddySessionBlueprint.ts @@ -13,6 +13,7 @@ export interface BuddySessionIdentity { executionProfile: BuddyExecutionProfile grantRevision: string resourceRevision: string + skillRevision?: string scratchRoot: string sessionMode: BuddySessionMode spaceId: string | null @@ -50,6 +51,7 @@ export function toBuddySessionIdentity( executionProfile: blueprint.executionProfile, grantRevision: blueprint.grantRevision, resourceRevision: blueprint.resources.revision, + skillRevision: blueprint.resources.skillRevision, scratchRoot: blueprint.scratchRoot, sessionMode: blueprint.sessionMode, spaceId: blueprint.space?.id ?? null, diff --git a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts index b242095a..fd495200 100644 --- a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts +++ b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts @@ -105,83 +105,98 @@ export class BuddySessionFactory { services: this.#options.services, }) }) - const tree = await this.#options.tree.open(run, blueprint.canonicalRoot, selected.model) - let reusable: ReturnType | undefined - const session = await createBuddySession({ - getInputMessages: () => reusable?.getInputContext?.().messages ?? [], - sessionManager: tree.manager, - agentDir: this.#options.agentDirectory, - approvalPolicy: blueprint.approvalPolicy, - branchId: blueprint.branchId, - canonicalRoot: blueprint.canonicalRoot, - conversationsDirectory: this.#options.conversationsDirectory, - conversationId: blueprint.conversationId, - cwd: blueprint.canonicalRoot, - executionProfile: blueprint.executionProfile, - getServiceTier: extensions.getServiceTier, - getPendingInput: () => extensions.inputReferences.pending, - inProcessExtensions: extensions.inProcessExtensions, - model: selected.model, - modelRuntime: selected.runtime, - resources: blueprint.resources, - thinkingLevel: input.thinkingLevel, - }) - - const inputWorkspace = new AttachmentToolWorkspace(blueprint.scratchRoot) - let unsubscribePreferences: (() => void) | undefined - reusable = createReusableBuddySession({ - skillReferences: blueprint.resources.skillReferences, - tree, - assertModelAccess: async (provider, model, contextWindow, maxTokens) => { - return this.#options.models.resolveAvailable({ - contextWindow, - maxTokens, - modelId: model, - providerId: provider, - }) - }, - runContext: extensions.runContext, - session: session.session, - shutdown: (reason) => { - unsubscribePreferences?.() - return events.scope({ runId: undefined, operationId: undefined, parentOperationId: undefined }).operation('session.close', () => session.shutdown(reason)) - }, - inputReferences: extensions.inputReferences, - getInputMetadata: ids => this.#options.services.attachmentService.getInputMetadata(ids, blueprint.conversationId), - prepareInputImages: (images, model) => this.#options.services.attachmentService.prepareInputImages(images, blueprint.conversationId, model), - materializeDocuments: input => this.#options.services.attachmentService.materializeDocumentInputs( - input.documents ?? [], - blueprint.conversationId, - ), - materializeInput: async (input, images) => { - const resources = await this.#options.services.attachmentService.materializeInputResources(input, blueprint.conversationId, inputWorkspace) - return [ - { text: input.prompt, type: 'text' as const }, - ...resources ? [{ text: resources, type: 'text' as const }] : [], - ...input.images.flatMap((reference) => { - const prepared = images?.get(reference.attachmentId) - if (!prepared) - throw new Error('RESOURCE_MATERIALIZATION_FAILED') - return [ - { type: 'text' as const, text: `Native attachment: ${reference.attachmentId}${prepared.note ? `\n${prepared.note}` : ''}` }, - prepared.image, - ] - }), - ] - }, - }) try { - unsubscribePreferences = await this.#options.bindPreferences?.(reusable.applyPreferences) + const tree = await this.#options.tree.open(run, blueprint.canonicalRoot, selected.model) + let reusable: ReturnType | undefined + const session = await createBuddySession({ + getInputMessages: () => reusable?.getInputContext?.().messages ?? [], + sessionManager: tree.manager, + agentDir: this.#options.agentDirectory, + approvalPolicy: blueprint.approvalPolicy, + branchId: blueprint.branchId, + canonicalRoot: blueprint.canonicalRoot, + conversationsDirectory: this.#options.conversationsDirectory, + conversationId: blueprint.conversationId, + cwd: blueprint.canonicalRoot, + executionProfile: blueprint.executionProfile, + getServiceTier: extensions.getServiceTier, + getPendingInput: () => extensions.inputReferences.pending, + inProcessExtensions: extensions.inProcessExtensions, + model: selected.model, + modelRuntime: selected.runtime, + resources: blueprint.resources, + thinkingLevel: input.thinkingLevel, + }) + + const inputWorkspace = new AttachmentToolWorkspace(blueprint.scratchRoot) + let unsubscribePreferences: (() => void) | undefined + reusable = createReusableBuddySession({ + skillReferences: blueprint.resources.skillReferences, + tree, + assertModelAccess: async (provider, model, contextWindow, maxTokens) => { + return this.#options.models.resolveAvailable({ + contextWindow, + maxTokens, + modelId: model, + providerId: provider, + }) + }, + runContext: extensions.runContext, + session: session.session, + shutdown: (reason) => { + unsubscribePreferences?.() + return events.scope({ runId: undefined, operationId: undefined, parentOperationId: undefined }).operation('session.close', async () => { + const results = await Promise.allSettled([extensions.dispose(), session.shutdown(reason)]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) + throw new AggregateError(failures.map(result => result.reason), 'SESSION_CLOSE_FAILED') + }) + }, + inputReferences: extensions.inputReferences, + getInputMetadata: ids => this.#options.services.attachmentService.getInputMetadata(ids, blueprint.conversationId), + prepareInputImages: (images, model) => this.#options.services.attachmentService.prepareInputImages(images, blueprint.conversationId, model), + materializeDocuments: input => this.#options.services.attachmentService.materializeDocumentInputs( + input.documents ?? [], + blueprint.conversationId, + ), + materializeInput: async (input, images) => { + const resources = await this.#options.services.attachmentService.materializeInputResources(input, blueprint.conversationId, inputWorkspace) + return [ + { text: input.prompt, type: 'text' as const }, + ...resources ? [{ text: resources, type: 'text' as const }] : [], + ...input.images.flatMap((reference) => { + const prepared = images?.get(reference.attachmentId) + if (!prepared) + throw new Error('RESOURCE_MATERIALIZATION_FAILED') + return [ + { type: 'text' as const, text: `Native attachment: ${reference.attachmentId}${prepared.note ? `\n${prepared.note}` : ''}` }, + prepared.image, + ] + }), + ] + }, + }) + try { + unsubscribePreferences = await this.#options.bindPreferences?.(reusable.applyPreferences) + } + catch (error) { + await reusable.shutdown('quit') + throw error + } + return { + piSessionFile: session.piSessionFile, + resourceRevisions: extensions.resourceRevisions, + recoveredFromProductHistory: tree.recoveredFromProductHistory, + recoveryDegradation: tree.recoveryDegradation, + session: reusable, + } } catch (error) { - await reusable.shutdown('quit') + try { + await extensions.dispose() + } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'SESSION_INITIALIZATION_FAILED') } throw error } - return { - piSessionFile: session.piSessionFile, - recoveredFromProductHistory: tree.recoveredFromProductHistory, - recoveryDegradation: tree.recoveryDegradation, - session: reusable, - } } } diff --git a/apps/buddy/service/src/agent/sessions/BuddySessionRegistry.ts b/apps/buddy/service/src/agent/sessions/BuddySessionRegistry.ts index beb28ef4..d9a50b0d 100644 --- a/apps/buddy/service/src/agent/sessions/BuddySessionRegistry.ts +++ b/apps/buddy/service/src/agent/sessions/BuddySessionRegistry.ts @@ -1,21 +1,30 @@ +import type { Event, ListenerErrorHandler } from '../../../../shared/events/Emitter' +import type { BuddyCapabilityResourceRevision } from '../extensions/BuddyCapability' import type { BuddySessionIdentity } from './BuddySessionBlueprint' import type { BuddySessionShutdownReason } from './ReusableBuddySession' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' export interface BuddySessionBinding { - piSessionFile: string - recoveredFromProductHistory?: boolean - recoveryDegradation?: { - missingAttachmentIds: readonly string[] - recoveredImageCount: number + readonly resourceRevisions?: readonly BuddyCapabilityResourceRevision[] + readonly piSessionFile: string + readonly recoveredFromProductHistory?: boolean + readonly recoveryDegradation?: { + readonly missingAttachmentIds: readonly string[] + readonly recoveredImageCount: number } - session: TSession + readonly session: TSession } export interface DisposableBuddySession { + getModelUsage?: () => { readonly providerId: string, readonly modelId: string } | null shutdown: (reason: BuddySessionShutdownReason) => Promise } interface BuddySessionEntry { + id: string + identity: Readonly binding: BuddySessionBinding | null promise: Promise> requestedPiSessionFile: string | null @@ -32,6 +41,32 @@ interface ActiveRun { export interface BuddySessionRegistryOptions { maxSessions?: number + onListenerError?: ListenerErrorHandler +} + +export interface BuddySessionSnapshot { + readonly id: string + readonly identity: Readonly + readonly model: { readonly providerId: string, readonly modelId: string } | null + readonly status: 'pending' | 'ready' | 'disposed' | 'failed' + readonly invalidationPending: boolean + readonly cleanup: 'none' | 'pending' | 'failed' + readonly recoveryPending: boolean + readonly resourceRevisions: readonly BuddyCapabilityResourceRevision[] +} +export interface BuddySessionChange { + readonly revision: number + readonly type: 'registered' | 'ready' | 'startup-failed' | 'recovery-acknowledged' | 'invalidation-pending' | 'removed' | 'disposed' | 'cleanup-failed' + readonly session: BuddySessionSnapshot +} +export interface BuddySessionInvalidationResult { + readonly matched: number + readonly pending: number + readonly degraded: number +} + +export interface BuddyRunRelease { + readonly cleanup: 'completed' | 'degraded' } export class BuddySessionRegistry { @@ -45,11 +80,26 @@ export class BuddySessionRegistry { readonly #maxSessions: number readonly #pendingFactories = new Set>() readonly #cleanupFailures: unknown[] = [] + readonly #runCleanup = new Map() + readonly #cleanupStates = new Map() + readonly #changes: Emitter #disposal: Promise | null = null #accessSequence = 0 + #revision = 0 constructor(options: BuddySessionRegistryOptions = {}) { this.#maxSessions = Math.max(1, Math.floor(options.maxSessions ?? 8)) + this.#changes = new Emitter(options.onListenerError ?? (() => console.error('SESSION_OBSERVER_FAILED'))) + } + + readonly onDidChange: Event = (listener, options) => this.#changes.event(listener, options) + + get revision(): number { + return this.#revision + } + + snapshot(): readonly BuddySessionSnapshot[] { + return Object.freeze([...this.#sessions].map(([key, entry]) => this.#snapshotEntry(entry, this.#pendingInvalidations.has(key))).concat([...this.#cleanupStates.values()])) } async getOrCreate( @@ -59,6 +109,7 @@ export class BuddySessionRegistry { ): Promise> { if (this.#disposal) throw new BuddySessionLifecycleAbortError() + identity = copyEventSnapshot(identity) const conversationKey = createConversationKey(identity) const boundRoot = this.#conversationRoots.get(conversationKey) if (boundRoot !== undefined && boundRoot !== identity.canonicalRoot) @@ -73,7 +124,7 @@ export class BuddySessionRegistry { throw new BuddySessionBindingError() } this.#touch(sessionKey) - return existing.promise + return existing.binding ?? existing.promise } for (const [candidateKey, candidateIdentity] of [...this.#identities]) { @@ -85,15 +136,19 @@ export class BuddySessionRegistry { throw new BuddySessionLifecycleAbortError() this.#conversationRoots.set(conversationKey, identity.canonicalRoot) - const entry = this.#createEntry(piSessionFile, factory) + const entry = this.#createEntry(identity, piSessionFile, factory) this.#sessions.set(sessionKey, entry) this.#identities.set(sessionKey, identity) + this.#publish('registered', this.#snapshotEntry(entry)) try { const binding = await entry.promise if (this.#sessions.get(sessionKey) !== entry) throw new BuddySessionLifecycleAbortError() + this.#publish('ready', this.#snapshotEntry(entry, this.#pendingInvalidations.has(sessionKey))) this.#touch(sessionKey) await this.#evictIdleSessions(sessionKey) + if (this.#sessions.get(sessionKey) !== entry) + throw new BuddySessionLifecycleAbortError() return binding } catch (error) { @@ -103,13 +158,26 @@ export class BuddySessionRegistry { this.#lastUsed.delete(sessionKey) if (![...this.#identities.values()].some(candidate => createConversationKey(candidate) === conversationKey)) this.#conversationRoots.delete(conversationKey) + this.#publish('startup-failed', this.#snapshotEntry(entry)) } throw error } } + acknowledgeRecovery(identity: BuddySessionIdentity, expected: BuddySessionBinding): boolean { + const key = createSessionKey(identity) + const entry = this.#sessions.get(key) + if (!entry || entry.binding !== expected || !expected.recoveredFromProductHistory) + return false + const { recoveredFromProductHistory: _recovered, recoveryDegradation: _degradation, ...binding } = expected + entry.binding = Object.freeze(binding) + this.#publish('recovery-acknowledged', this.#snapshotEntry(entry, this.#pendingInvalidations.has(key))) + return true + } + getActiveRun(identity: BuddySessionIdentity): ActiveRun | undefined { - return this.#activeRuns.get(createConversationKey(identity)) + const run = this.#activeRuns.get(createConversationKey(identity)) + return run ? Object.freeze({ ...run }) : undefined } getReady(conversationId: string, branchId: string): TSession | null { @@ -127,6 +195,10 @@ export class BuddySessionRegistry { return this.#invalidate(() => true) } + invalidateMode(mode: BuddySessionIdentity['sessionMode']): Promise { + return this.#invalidate(identity => identity.sessionMode === mode) + } + invalidateRoot(canonicalRoot: string): Promise { return this.#invalidate(identity => identity.canonicalRoot === canonicalRoot) } @@ -139,6 +211,10 @@ export class BuddySessionRegistry { return this.#invalidate(identity => identity.conversationId === conversationId) } + invalidateConversationWithResult(conversationId: string): Promise { + return this.invalidateMatching(identity => identity.conversationId === conversationId) + } + invalidateSession(identity: BuddySessionIdentity): Promise { const sessionKey = createSessionKey(identity) return this.#invalidate(candidate => createSessionKey(candidate) === sessionKey) @@ -149,6 +225,7 @@ export class BuddySessionRegistry { runId: string, signal: AbortSignal | undefined, operation: () => Promise, + onReleased?: (result: BuddyRunRelease) => void, ): Promise { const conversationKey = createConversationKey(identity) const previous = this.#runTails.get(conversationKey) ?? Promise.resolve() @@ -165,6 +242,7 @@ export class BuddySessionRegistry { throw new BuddySessionLifecycleAbortError() signal?.throwIfAborted() this.#activeRuns.set(conversationKey, { runId, signal }) + this.#runCleanup.set(conversationKey, 'completed') return await operation() } finally { @@ -177,6 +255,9 @@ export class BuddySessionRegistry { release() if (this.#runTails.get(conversationKey) === tail) this.#runTails.delete(conversationKey) + const cleanup = this.#runCleanup.get(conversationKey) ?? 'completed' + this.#runCleanup.delete(conversationKey) + onReleased?.({ cleanup }) } } } @@ -196,13 +277,20 @@ export class BuddySessionRegistry { this.#pendingInvalidations.clear() this.#runTails.clear() this.#sessions.clear() + this.#cleanupStates.clear() + this.#changes.dispose() const failures = [...this.#cleanupFailures.splice(0), ...results.filter(result => result.status === 'rejected').map(result => result.reason)] if (failures.length) throw new AggregateError(failures, 'Session shutdown failed') } async #invalidate(predicate: (identity: BuddySessionIdentity) => boolean): Promise { - let count = 0 + return (await this.invalidateMatching(predicate)).matched + } + + async invalidateMatching(predicate: (identity: Readonly) => boolean, options?: { sessionIds: readonly string[] }): Promise { + const selected = options ? new Set(options.sessionIds) : null + const affected = new Set([...this.#cleanupStates.values()].filter(entry => (!selected || selected.has(entry.id)) && predicate(entry.identity)).map(entry => entry.id)) const candidates = [...this.#identities] .map(([sessionKey, identity]) => ({ entry: this.#sessions.get(sessionKey), @@ -210,18 +298,22 @@ export class BuddySessionRegistry { sessionKey, })) for (const { entry, identity, sessionKey } of candidates) { - if (!predicate(identity)) + if (!entry || (selected && !selected.has(entry.id)) || !predicate(identity)) continue - count += 1 + affected.add(entry.id) const conversationKey = createConversationKey(identity) if (this.#activeRuns.has(conversationKey)) { - this.#pendingInvalidations.add(sessionKey) + if (!this.#pendingInvalidations.has(sessionKey)) { + this.#pendingInvalidations.add(sessionKey) + this.#publish('invalidation-pending', this.#snapshotEntry(entry, true)) + } continue } if (entry) await this.#settleSessionDisposal(sessionKey, 'invalidate', entry) } - return count + const remaining = this.snapshot().filter(entry => affected.has(entry.id)) + return Object.freeze({ matched: affected.size, pending: remaining.filter(entry => entry.invalidationPending || entry.cleanup === 'pending').length, degraded: remaining.filter(entry => entry.cleanup === 'failed').length }) } async #flushInvalidations(conversationKey: string): Promise { @@ -249,8 +341,18 @@ export class BuddySessionRegistry { this.#identities.delete(sessionKey) this.#lastUsed.delete(sessionKey) this.#pendingInvalidations.delete(sessionKey) + const removing = copyEventSnapshot({ ...this.#snapshotEntry(entry), status: 'disposed' as const, invalidationPending: false, cleanup: 'pending' as const }) + this.#cleanupStates.set(entry.id, removing) + this.#publish('removed', removing) + const pendingFactory = entry.status === 'pending' try { await this.#disposeEntry(entry, reason) + if (!pendingFactory) + this.#finishCleanup(entry) + } + catch (error) { + this.#finishCleanup(entry, true) + throw error } finally { if (identity) { @@ -283,10 +385,19 @@ export class BuddySessionRegistry { reason: BuddySessionShutdownReason, entry: BuddySessionEntry, ): Promise { - await this.#disposeSession(sessionKey, reason, entry).catch(() => {}) + const identity = this.#identities.get(sessionKey) + const conversationKey = identity ? createConversationKey(identity) : undefined + const degrade = () => { + if (conversationKey && this.#runCleanup.has(conversationKey)) + this.#runCleanup.set(conversationKey, 'degraded') + } + if (entry.status === 'pending') + degrade() + await this.#disposeSession(sessionKey, reason, entry).catch(degrade) } #createEntry( + identity: BuddySessionIdentity, requestedPiSessionFile: string | null, factory: () => Promise>, ): BuddySessionEntry { @@ -297,6 +408,8 @@ export class BuddySessionRegistry { resolve = resolvePromise }) const entry: BuddySessionEntry = { + id: randomUUID(), + identity, binding: null, promise, requestedPiSessionFile, @@ -317,13 +430,18 @@ export class BuddySessionRegistry { if (entry.status === 'disposed') { const reason = entry.shutdownReason ?? 'invalidate' await binding.session.shutdown(reason) + this.#finishCleanup(entry) return } - entry.binding = binding + entry.binding = Object.freeze({ ...binding, resourceRevisions: copyEventSnapshot(binding.resourceRevisions ?? []), ...(binding.recoveryDegradation ? { recoveryDegradation: copyEventSnapshot(binding.recoveryDegradation) } : {}) }) entry.status = 'ready' - entry.resolve(binding) + entry.resolve(entry.binding) }, (error) => { + if (entry.status === 'disposed') { + this.#finishCleanup(entry) + return + } if (entry.status !== 'pending') return entry.status = 'failed' @@ -334,6 +452,7 @@ export class BuddySessionRegistry { void completion.then(() => this.#pendingFactories.delete(completion), (error) => { this.#pendingFactories.delete(completion) this.#cleanupFailures.push(error) + this.#finishCleanup(entry, true) }) return entry } @@ -361,6 +480,23 @@ export class BuddySessionRegistry { this.#accessSequence += 1 this.#lastUsed.set(sessionKey, this.#accessSequence) } + + #snapshotEntry(entry: BuddySessionEntry, invalidationPending = false): BuddySessionSnapshot { + return copyEventSnapshot({ id: entry.id, identity: entry.identity, model: entry.binding?.session.getModelUsage?.() ?? null, status: entry.status, invalidationPending, cleanup: 'none', recoveryPending: entry.binding?.recoveredFromProductHistory === true, resourceRevisions: entry.binding?.resourceRevisions ?? [] }) + } + + #publish(type: BuddySessionChange['type'], session: BuddySessionSnapshot): void { + this.#changes.fire(Object.freeze({ type, session, revision: ++this.#revision })) + } + + #finishCleanup(entry: BuddySessionEntry, failed = false): void { + const snapshot = copyEventSnapshot({ ...this.#snapshotEntry(entry), cleanup: failed ? 'failed' as const : 'none' as const }) + if (failed) + this.#cleanupStates.set(entry.id, snapshot) + else + this.#cleanupStates.delete(entry.id) + this.#publish(failed ? 'cleanup-failed' : 'disposed', snapshot) + } } export class BuddySessionBindingError extends Error { diff --git a/apps/buddy/service/src/agent/sessions/ReusableBuddySession.ts b/apps/buddy/service/src/agent/sessions/ReusableBuddySession.ts index 58c02b12..74254bc7 100644 --- a/apps/buddy/service/src/agent/sessions/ReusableBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/ReusableBuddySession.ts @@ -16,6 +16,7 @@ export interface BuddySessionEventSource { } export interface ReusableBuddySession extends BuddySessionEventSource { + getModelUsage?: () => { providerId: string, modelId: string } | null getCacheWarmingStatus?: () => LocalCacheWarmingStatus getInputContext?: () => { messages: AgentSession['messages'] } steer?: (prepare: () => BuddyInputReferenceV1, skills?: readonly SkillReference[]) => boolean diff --git a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts index ad012690..1f667fb3 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts @@ -69,6 +69,7 @@ describe('buddySessionBlueprintService', () => { executionProfile: 'workspace_write', grantRevision: blueprint.grantRevision, resourceRevision: blueprint.resources.revision, + skillRevision: blueprint.resources.skillRevision, scratchRoot, sessionMode: 'interactive', spaceId: 'space-1', diff --git a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionRegistry.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionRegistry.spec.ts index bcc3cbdb..43265a94 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionRegistry.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionRegistry.spec.ts @@ -3,6 +3,116 @@ import { describe, expect, it, vi } from 'vitest' import { BuddySessionRegistry } from '../BuddySessionRegistry' describe('buddySessionRegistry', () => { + it('acknowledges recovery only for the current immutable binding incarnation', async () => { + const registry = new BuddySessionRegistry() + const sessionIdentity = identity('branch-1') + const recovered = await registry.getOrCreate(sessionIdentity, null, async () => ({ piSessionFile: '/sessions/one', session: new TestSession(), recoveredFromProductHistory: true, recoveryDegradation: { missingAttachmentIds: ['missing-attachment'], recoveredImageCount: 0 } })) + expect(registry.snapshot()[0]?.recoveryPending).toBe(true) + expect(registry.acknowledgeRecovery(sessionIdentity, recovered)).toBe(true) + expect(recovered.recoveredFromProductHistory).toBe(true) + expect(registry.snapshot()[0]?.recoveryPending).toBe(false) + const current = await registry.getOrCreate(sessionIdentity, '/sessions/one', async () => { + throw new Error('must reuse') + }) + expect(current.recoveredFromProductHistory).toBeUndefined() + expect(registry.acknowledgeRecovery(sessionIdentity, recovered)).toBe(false) + await registry.invalidateAll() + const replacement = await registry.getOrCreate(sessionIdentity, null, async () => ({ piSessionFile: '/sessions/two', session: new TestSession(), recoveredFromProductHistory: true })) + expect(registry.acknowledgeRecovery(sessionIdentity, recovered)).toBe(false) + expect(registry.snapshot()[0]?.recoveryPending).toBe(true) + expect(registry.acknowledgeRecovery(sessionIdentity, replacement)).toBe(true) + await registry.dispose() + }) + + it('keeps pending factory cleanup observable through late failure and repeated invalidation', async () => { + const registry = new BuddySessionRegistry() + const factory = Promise.withResolvers<{ piSessionFile: string, session: TestSession }>() + const creation = registry.getOrCreate(identity('branch-1'), null, () => factory.promise) + const rejected = expect(creation).rejects.toMatchObject({ name: 'AbortError' }) + const changes: string[] = [] + registry.onDidChange(event => changes.push(event.type)) + expect(await registry.invalidateConversationWithResult('conversation-1')).toEqual({ matched: 1, pending: 1, degraded: 0 }) + expect(registry.snapshot()[0]?.cleanup).toBe('pending') + const session = new FailingShutdownSession() + factory.resolve({ piSessionFile: '/sessions/late', session }) + await rejected + await vi.waitFor(() => expect(registry.snapshot()[0]?.cleanup).toBe('failed')) + expect(changes).toEqual(['removed', 'cleanup-failed']) + expect(await registry.invalidateConversationWithResult('conversation-1')).toEqual({ matched: 1, pending: 0, degraded: 1 }) + await expect(registry.dispose()).rejects.toThrow('Session shutdown failed') + }) + + it('captures identity and current model without exposing aliases or publishing a failed startup as ready', async () => { + let reported = 0 + const registry = new BuddySessionRegistry({ onListenerError: () => { + reported++ + } }) + const sourceIdentity = identity('branch-1') + const model = { providerId: 'provider-one', modelId: 'model-one' } + const session = Object.assign(new TestSession(), { getModelUsage: () => model }) + registry.onDidChange(() => { + throw new Error('private listener error') + }) + await registry.getOrCreate(sourceIdentity, null, async () => ({ piSessionFile: '/sessions/one', session })) + sourceIdentity.spaceId = null + sourceIdentity.resourceRevision = 'mutated' + const first = registry.snapshot()[0]! + expect(first.identity.resourceRevision).toBe('resources-1') + expect(Reflect.set(first.identity, 'canonicalRoot', '/elsewhere')).toBe(false) + expect(Reflect.set(first.model!, 'modelId', 'overwritten')).toBe(false) + model.modelId = 'model-two' + expect(first.model?.modelId).toBe('model-one') + expect(registry.snapshot()[0]?.model?.modelId).toBe('model-two') + await registry.invalidateAll() + const changes: string[] = [] + registry.onDidChange(event => changes.push(event.type)) + await expect(registry.getOrCreate(identity('branch-1'), null, async () => { + throw new Error('factory failed') + })).rejects.toThrow('factory failed') + expect(changes).toEqual(['registered', 'startup-failed']) + expect(registry.snapshot()).toEqual([]) + expect(reported).toBeGreaterThan(0) + await registry.dispose() + }) + + it('reports failed deferred invalidation only after release without replacing the operation result', async () => { + const registry = new BuddySessionRegistry() + const runIdentity = identity('branch-1') + const cleanup = Promise.withResolvers() + const session = new TestSession() + session.shutdown = () => cleanup.promise + await registry.getOrCreate(runIdentity, null, async () => ({ piSessionFile: '/sessions/one.jsonl', session })) + const released: string[] = [] + const result = registry.withConversationRun(runIdentity, 'run-1', undefined, async () => { + await registry.invalidateConversation(runIdentity.conversationId) + return 'completed result' + }, (receipt) => { + expect(registry.getActiveRun(runIdentity)).toBeUndefined() + released.push(receipt.cleanup) + }) + await vi.waitFor(() => expect(registry.getReady(runIdentity.conversationId, runIdentity.branchId)).toBeNull()) + expect(released).toEqual([]) + cleanup.reject(new Error('Shutdown unavailable')) + expect(await result).toBe('completed result') + expect(released).toEqual(['degraded']) + await registry.dispose() + }) + + it('invalidates interactive contributions without replacing automation sessions', async () => { + const registry = new BuddySessionRegistry() + const interactive = new TestSession() + const automation = new TestSession() + const automationIdentity = { ...identity('branch-2'), conversationId: 'automation-task', sessionMode: 'automation_background' as const } + await registry.getOrCreate(identity('branch-1'), null, async () => ({ piSessionFile: '/sessions/interactive.jsonl', session: interactive })) + const bound = await registry.getOrCreate(automationIdentity, null, async () => ({ piSessionFile: '/sessions/automation.jsonl', session: automation })) + expect(await registry.invalidateMode('interactive')).toBe(1) + expect(interactive.shutdownReasons).toEqual(['invalidate']) + expect(automation.shutdownReasons).toEqual([]) + expect(await registry.getOrCreate(automationIdentity, '/sessions/automation.jsonl', async () => { + throw new Error('Must retain the automation session') + })).toBe(bound) + await registry.invalidateAll() + }) it('creates one Pi session binding for the same Buddy branch', async () => { const registry = new BuddySessionRegistry() let creates = 0 diff --git a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts index fde355a5..7daef662 100644 --- a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts @@ -208,6 +208,7 @@ export function createReusableBuddySession( preferences = next applyCacheWarming(options.runContext.current && !options.runContext.current.signal.aborted ? preferences.cacheWarming : 'off') }, + getModelUsage: () => session.model ? { providerId: session.model.provider, modelId: session.model.id } : null, getInputContext: () => { const messages = [...session.messages] for (const message of messages) { diff --git a/apps/buddy/service/src/agent/sessions/tree/BuddyConversationTree.ts b/apps/buddy/service/src/agent/sessions/tree/BuddyConversationTree.ts index eeebcb92..298676b0 100644 --- a/apps/buddy/service/src/agent/sessions/tree/BuddyConversationTree.ts +++ b/apps/buddy/service/src/agent/sessions/tree/BuddyConversationTree.ts @@ -17,33 +17,57 @@ export interface BuddyConversationTreeOptions { repository: ConversationTreeRepository runs: Pick recovery: Pick + onObserverError?: (error: unknown) => void } export class BuddyConversationTree { readonly #store: BuddyTreeStore - - readonly options: BuddyConversationTreeOptions + readonly #options: BuddyConversationTreeOptions + readonly #pending = new Set>() + readonly #openings = new Map>() + #stopping = false + #closing: Promise | undefined + readonly onDidCommit: BuddyTreeStore['onDidCommit'] + readonly onDidFail: BuddyTreeStore['onDidFail'] constructor(options: BuddyConversationTreeOptions) { - this.options = options + this.#options = options this.#store = new BuddyTreeStore(options) + this.onDidCommit = this.#store.onDidCommit + this.onDidFail = this.#store.onDidFail + } + + open(run: RunRecord, cwd: string, model: Model) { + const captured = { ...run } + const previous = this.#openings.get(run.conversationId) ?? Promise.resolve() + const result = this.#run(() => previous.catch(() => {}).then(() => this.#open(captured, cwd, model))) + this.#openings.set(run.conversationId, result) + void result.finally(() => { + if (this.#openings.get(run.conversationId) === result) + this.#openings.delete(run.conversationId) + }).catch(() => {}) + return result } - async open(run: RunRecord, cwd: string, model: Model) { + async #open(run: RunRecord, cwd: string, model: Model) { const journal = await this.#store.open(run.conversationId, cwd) - const cursor = new BuddyConversationTreeCursor(this.options, journal, model) + const cursor = new BuddyConversationTreeCursor(this.#options, journal, model) cursor.manager.branch(await cursor.resolveStart(run)) return cursor } - async preview(conversationId: string, branchId: string, cwd: string, model: Model, legacySessionFile: string | null) { - const snapshot = await this.snapshot(conversationId, branchId, cwd) + preview(conversationId: string, branchId: string, cwd: string, model: Model, legacySessionFile: string | null) { + return this.#run(() => this.#preview(conversationId, branchId, cwd, model, legacySessionFile)) + } + + async #preview(conversationId: string, branchId: string, cwd: string, model: Model, legacySessionFile: string | null) { + const snapshot = await this.#snapshot(conversationId, branchId, cwd) if (snapshot) return snapshot const legacy = await this.#store.readLegacy(conversationId, cwd, legacySessionFile) if (legacy) return legacy.manager - const recovered = await this.options.recovery.create({ + const recovered = await this.#options.recovery.create({ conversationId, branchId, fallbackModel: model, @@ -55,7 +79,11 @@ export class BuddyConversationTree { return manager } - async snapshot(conversationId: string, branchId: string, cwd: string, sourceRunId?: string, position: 'before' | 'after' = 'after'): Promise { + snapshot(conversationId: string, branchId: string, cwd: string, sourceRunId?: string, position: 'before' | 'after' = 'after'): Promise { + return this.#run(() => this.#snapshot(conversationId, branchId, cwd, sourceRunId, position)) + } + + async #snapshot(conversationId: string, branchId: string, cwd: string, sourceRunId?: string, position: 'before' | 'after' = 'after'): Promise { const journal = await this.#store.read(conversationId, cwd) if (!journal) return null @@ -70,6 +98,22 @@ export class BuddyConversationTree { journal.manager.branch(checkpoint.id) return journal.manager } + + dispose(): Promise { + this.#stopping = true + this.#closing ??= Promise.allSettled([...this.#pending]).then(() => this.#store.dispose()) + return this.#closing + } + + #run(operation: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new Error('Conversation tree is stopped')) + const accepted = Promise.withResolvers() + this.#pending.add(accepted.promise) + void accepted.promise.finally(() => this.#pending.delete(accepted.promise)).catch(() => {}) + void operation().then(accepted.resolve, accepted.reject) + return accepted.promise + } } export class BuddyConversationTreeCursor { @@ -77,52 +121,52 @@ export class BuddyConversationTreeCursor { #activeRun: RunRecord | null = null readonly #recovery: BuddyTreeRecovery - readonly options: BuddyConversationTreeOptions - readonly journal: BuddyTreeJournal + readonly #options: BuddyConversationTreeOptions + readonly #journal: BuddyTreeJournal constructor(options: BuddyConversationTreeOptions, journal: BuddyTreeJournal, model: Model) { - this.options = options - this.journal = journal + this.#options = options + this.#journal = journal this.#recovery = new BuddyTreeRecovery(journal, model, options) } - get manager() { return this.journal.manager } - get rootId() { return this.journal.rootId } + get manager() { return this.#journal.manager } + get rootId() { return this.#journal.rootId } get recoveredFromProductHistory() { return this.#recovery.recoveredFromProductHistory } get recoveryDegradation() { return this.#recovery.recoveryDegradation } async begin(session: AgentSession, runId: string) { - const run = this.options.runs.findById(runId) + const run = this.#options.runs.findById(runId) if (!run) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') const target = await this.resolveStart(run) - this.journal.validateAncestry(target) + this.#journal.validateAncestry(target) const result = await session.navigateTree(target, { summarize: false }) if (result.cancelled || result.aborted || this.manager.getLeafId() !== target) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') this.#activeRun = run - this.manager.branch(this.journal.appendCheckpoint(run, 'before')) + this.manager.branch(this.#journal.appendCheckpoint(run, 'before')) } finish() { const run = this.#activeRun if (!run) return - this.manager.branch(this.journal.appendCheckpoint(run, 'after')) + this.manager.branch(this.#journal.appendCheckpoint(run, 'after')) this.#activeRun = null } async resolveStart(run: RunRecord): Promise { - this.journal.assertConversation(run.conversationId) - const explicit = this.options.repository.findSource(run.id) + this.#journal.assertConversation(run.conversationId) + const explicit = this.#options.repository.findSource(run.id) if (explicit) return this.#checkpoint(explicit.sourceRunId, explicit.position) - const history = this.options.conversations.listBranchMessages(run.conversationId, run.branchId) + const history = this.#options.conversations.listBranchMessages(run.conversationId, run.branchId) const boundary = run.purpose === 'conversation.compaction' ? history.length : history.findIndex(message => message.id === run.triggeringMessageId) if (boundary < 0) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') const visible = new Set(history.slice(0, boundary).filter(message => message.role === 'user').map(message => message.id)) - const earlier = this.options.repository.listRuns(run.conversationId).filter(candidate => candidate.id !== run.id + const earlier = this.#options.repository.listRuns(run.conversationId).filter(candidate => candidate.id !== run.id && candidate.branchId === run.branchId && visible.has(candidate.triggeringMessageId) && candidate.status !== 'queued' && candidate.status !== 'running' && candidate.startedAt <= run.startedAt) const latest = earlier.at(-1) @@ -132,7 +176,7 @@ export class BuddyConversationTreeCursor { if (message.runId) return this.#checkpoint(message.runId, 'after') if (message.role === 'user') { - const previous = this.options.repository.listRuns(run.conversationId).filter(candidate => candidate.id !== run.id + const previous = this.#options.repository.listRuns(run.conversationId).filter(candidate => candidate.id !== run.id && candidate.branchId === message.branchId && candidate.triggeringMessageId === message.id).at(-1) if (previous) return this.#checkpoint(previous.id, 'after') @@ -143,11 +187,11 @@ export class BuddyConversationTreeCursor { async #checkpoint(runId: string, position: 'before' | 'after'): Promise { const key = `${runId}:${position}` - const run = this.options.runs.findById(runId) + const run = this.#options.runs.findById(runId) if (!run || this.#resolving.has(key)) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') - this.journal.assertConversation(run.conversationId) - const existing = this.journal.findCheckpoint(runId, position) + this.#journal.assertConversation(run.conversationId) + const existing = this.#journal.findCheckpoint(runId, position) if (existing) { return existing } @@ -156,7 +200,7 @@ export class BuddyConversationTreeCursor { if (run.piSessionFile === this.manager.getSessionFile()) { if (position === 'before') return this.resolveStart(run) - const before = this.journal.findCheckpoint(run.id, 'before') + const before = this.#journal.findCheckpoint(run.id, 'before') if (before && run.status !== 'queued' && run.status !== 'running') { let endpoint = before const entries = this.manager.getEntries() @@ -166,7 +210,7 @@ export class BuddyConversationTreeCursor { if (entry.parentId === endpoint) endpoint = entry.id } - return this.journal.appendCheckpoint(run, position, endpoint, false) + return this.#journal.appendCheckpoint(run, position, endpoint, { source: 'native_endpoint' }) } throw new BuddyAgentRunError('SESSION_STORAGE_UNAVAILABLE') } diff --git a/apps/buddy/service/src/agent/sessions/tree/BuddyTreeEvents.ts b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeEvents.ts new file mode 100644 index 00000000..2cfeba4f --- /dev/null +++ b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeEvents.ts @@ -0,0 +1,67 @@ +import type { EventSnapshot } from '../../../../../shared/events/eventTypes' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot' + +export type TreeRecoverySource = 'native_endpoint' | 'legacy' | 'product_history' + +export type BuddyTreeFact = { + operationId: string + conversationId: string + treeId: string +} & ( + | { kind: 'file.created' } + | { kind: 'binding.committed', reason: 'created' | 'replaced' | 'reconciled' } + | { kind: 'entries.imported', runId: string, branchId: string, entryCount: number } + | { kind: 'checkpoint.committed', runId: string, branchId: string, checkpointId: string, position: 'before' | 'after', recovery?: { source: TreeRecoverySource, missingAttachmentCount?: number, recoveredImageCount?: number } } +) + +export type BuddyTreeCommit = EventSnapshot +export type BuddyTreeFailure = EventSnapshot<{ + operationId: string + conversationId: string + stage: 'open' | 'binding' | 'checkpoint' | 'import' | 'import_index' + errorCode: 'SESSION_STORAGE_UNAVAILABLE' | 'CONVERSATION_BINDING_MISMATCH' +}> + +export class BuddyTreeEvents { + readonly #committed: Emitter + readonly #failed: Emitter + #revision = 0 + #disposed = false + readonly onDidCommit: Emitter['event'] + readonly onDidFail: Emitter['event'] + + constructor(onObserverError: (error: unknown) => void = () => {}) { + this.#committed = new Emitter(onObserverError) + this.#failed = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + this.onDidFail = this.#failed.event + } + + assertOpen() { + if (this.#disposed) + throw new Error('Conversation tree is stopped') + } + + commit(fact: BuddyTreeFact) { + this.#committed.fire(copyEventSnapshot({ ...fact, revision: ++this.#revision })) + } + + fail(conversationId: string, stage: BuddyTreeFailure['stage'], error: unknown, operationId = randomUUID()) { + this.#failed.fire(copyEventSnapshot({ + operationId, + conversationId, + stage, + errorCode: error && typeof error === 'object' && 'code' in error && error.code === 'CONVERSATION_BINDING_MISMATCH' + ? 'CONVERSATION_BINDING_MISMATCH' + : 'SESSION_STORAGE_UNAVAILABLE', + })) + } + + dispose() { + this.#disposed = true + this.#committed.dispose() + this.#failed.dispose() + } +} diff --git a/apps/buddy/service/src/agent/sessions/tree/BuddyTreeRecovery.ts b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeRecovery.ts index db85839d..93829abb 100644 --- a/apps/buddy/service/src/agent/sessions/tree/BuddyTreeRecovery.ts +++ b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeRecovery.ts @@ -5,50 +5,54 @@ import type { RunRecord } from '../../../storage/runRecord' import type { BuddySessionRecoveryService } from '../recovery/BuddySessionRecoveryService' import type { BuddyTreeJournal } from './BuddyTreeStore' import { createHash } from 'node:crypto' +import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot' import { BuddyAgentRunError } from '../../../runs/runError' import { readBuddyInputReference } from '../../context/BuddyInputReference' export class BuddyTreeRecovery { - recoveredFromProductHistory = false - recoveryDegradation: { missingAttachmentIds: readonly string[], recoveredImageCount: number } | undefined + #recoveredFromProductHistory = false + #recoveryDegradation: Readonly<{ missingAttachmentIds: readonly string[], recoveredImageCount: number }> | undefined - readonly journal: BuddyTreeJournal - readonly model: Model - readonly options: { + readonly #journal: BuddyTreeJournal + readonly #model: Model + readonly #options: { conversations: Pick recovery: Pick } - constructor(journal: BuddyTreeJournal, model: Model, options: BuddyTreeRecovery['options']) { - this.journal = journal - this.model = model - this.options = options + constructor(journal: BuddyTreeJournal, model: Model, options: { conversations: Pick, recovery: Pick }) { + this.#journal = journal + this.#model = model + this.#options = options } + get recoveredFromProductHistory() { return this.#recoveredFromProductHistory } + get recoveryDegradation() { return this.#recoveryDegradation } + async restore(run: RunRecord, position: 'before' | 'after'): Promise { const imported = await this.#importLegacy(run, position) if (imported) return imported - const history = this.options.conversations.listBranchMessages(run.conversationId, run.branchId) + const history = this.#options.conversations.listBranchMessages(run.conversationId, run.branchId) const index = history.findIndex(message => message.id === run.triggeringMessageId) const next = history.slice(index + 1).find(message => message.role === 'user') - const recovered = await this.options.recovery.create({ + const recovered = await this.#options.recovery.create({ branchId: run.branchId, conversationId: run.conversationId, - fallbackModel: this.model, + fallbackModel: this.#model, point: position === 'before' ? { kind: 'before_message', messageId: run.triggeringMessageId } : next ? { kind: 'before_message', messageId: next.id } : { kind: 'branch_head' }, }) - const id = this.journal.appendRecoveredMessages(run, position, recovered.messages) - this.recoveredFromProductHistory = true + const id = this.#journal.appendRecoveredMessages(run, position, recovered.messages, { missingAttachmentCount: recovered.missingAttachmentIds.length, recoveredImageCount: recovered.recoveredImageCount }) + this.#recoveredFromProductHistory = true if (recovered.missingAttachmentIds.length) - this.recoveryDegradation = { missingAttachmentIds: recovered.missingAttachmentIds, recoveredImageCount: recovered.recoveredImageCount } + this.#recoveryDegradation = copyEventSnapshot({ missingAttachmentIds: recovered.missingAttachmentIds, recoveredImageCount: recovered.recoveredImageCount }) return id } async #importLegacy(run: RunRecord, position: 'before' | 'after'): Promise { - const source = await this.journal.readLegacy(run.piSessionFile) + const source = await this.#journal.readLegacy(run.piSessionFile) if (!source) return null const { path, manager: legacy } = source @@ -72,15 +76,15 @@ export class BuddyTreeRecovery { if (entry.type === 'context_edit' && !sourceIds.has(entry.targetId)) throw new BuddyAgentRunError('SESSION_STORAGE_UNAVAILABLE') } - const imported: FileEntry[] = entries.filter(entry => !this.journal.manager.getEntry(mappedId(entry.id))).map(entry => ({ + const imported: FileEntry[] = entries.filter(entry => !this.#journal.manager.getEntry(mappedId(entry.id))).map(entry => ({ ...entry, id: mappedId(entry.id), - parentId: entry.parentId ? mappedId(entry.parentId) : this.journal.rootId, + parentId: entry.parentId ? mappedId(entry.parentId) : this.#journal.rootId, ...(entry.type === 'compaction' ? { firstKeptEntryId: mappedId(entry.firstKeptEntryId) } : {}), ...(entry.type === 'label' || entry.type === 'context_edit' ? { targetId: mappedId(entry.targetId) } : {}), ...(entry.type === 'branch_summary' ? { fromId: mappedId(entry.fromId) } : {}), })) - this.journal.appendEntries(imported) - return this.journal.appendCheckpoint(run, position, endpoint ? mappedId(endpoint) : this.journal.rootId, true) + this.#journal.appendEntries(run, imported) + return this.#journal.appendCheckpoint(run, position, endpoint ? mappedId(endpoint) : this.#journal.rootId, { source: 'legacy' }) } } diff --git a/apps/buddy/service/src/agent/sessions/tree/BuddyTreeStore.ts b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeStore.ts index 9df775fa..17ea7c56 100644 --- a/apps/buddy/service/src/agent/sessions/tree/BuddyTreeStore.ts +++ b/apps/buddy/service/src/agent/sessions/tree/BuddyTreeStore.ts @@ -1,6 +1,7 @@ import type { FileEntry, SessionEntry } from '@earendil-works/pi-coding-agent' import type { ConversationTreeRepository } from '../../../storage/conversationTreeRepository' import type { RunRecord } from '../../../storage/runRecord' +import type { BuddyTreeFact } from './BuddyTreeEvents' import { randomUUID } from 'node:crypto' import { appendFileSync, closeSync, fsyncSync, openSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, realpath } from 'node:fs/promises' @@ -9,6 +10,7 @@ import { SessionManager } from '@earendil-works/pi-coding-agent' import { containsCanonicalPath } from '../../../../../platform/filesystem/filePaths' import { BuddyAgentRunError } from '../../../runs/runError' import { toBuddySessionStorageError } from '../BuddySessionErrors' +import { BuddyTreeEvents } from './BuddyTreeEvents' const CHECKPOINT_TYPE = 'lexora.conversation.checkpoint.v1' const ROOT_TYPE = 'lexora.conversation.root.v1' @@ -22,27 +24,40 @@ export interface TreeCheckpoint { interface BuddyTreeStoreOptions { conversationsDirectory: string - repository: ConversationTreeRepository + repository: Pick + onObserverError?: (error: unknown) => void } export class BuddyTreeStore { - readonly options: BuddyTreeStoreOptions + readonly #options: BuddyTreeStoreOptions + readonly #events: BuddyTreeEvents + readonly onDidCommit: BuddyTreeEvents['onDidCommit'] + readonly onDidFail: BuddyTreeEvents['onDidFail'] constructor(options: BuddyTreeStoreOptions) { - this.options = options + this.#options = options + this.#events = new BuddyTreeEvents(options.onObserverError) + this.onDidCommit = this.#events.onDidCommit + this.onDidFail = this.#events.onDidFail } async open(conversationId: string, cwd: string) { + this.#events.assertOpen() + const operationId = randomUUID() + let stage: 'open' | 'binding' = 'open' try { - return await this.#open(conversationId, cwd) + return await this.#open(conversationId, cwd, operationId, () => { + stage = 'binding' + }) } catch (error) { + this.#events.fail(conversationId, stage, error, operationId) throw toBuddySessionStorageError(error) ?? error } } - async #open(conversationId: string, cwd: string) { - const binding = this.options.repository.findBinding(conversationId) + async #open(conversationId: string, cwd: string, operationId: string, beforeBinding: () => void) { + const binding = this.#options.repository.findBinding(conversationId) const directory = await this.#directory(conversationId) let manager: SessionManager | undefined let rootId: string | undefined @@ -72,11 +87,14 @@ export class BuddyTreeStore { const path = join(directory, binding ? `tree-${randomUUID()}.jsonl` : 'tree.jsonl') const seed = SessionManager.inMemory(cwd) rootId = seed.appendCustomEntry(ROOT_TYPE, { conversationId }) + let created = false try { const fd = openSync(path, 'wx', 0o600) try { writeFileSync(fd, `${[seed.getHeader()!, ...seed.getEntries()].map(entry => JSON.stringify(entry)).join('\n')}\n`) fsyncSync(fd) + created = true + this.#events.commit({ kind: 'file.created', conversationId, operationId, treeId: rootId }) } finally { closeSync(fd) @@ -93,13 +111,16 @@ export class BuddyTreeStore { rootId = root.id } manager = SessionManager.open(path, directory, cwd) - this.options.repository.bind(conversationId, path, rootId) + beforeBinding() + this.#options.repository.bind(conversationId, path, rootId) + this.#events.commit({ kind: 'binding.committed', conversationId, operationId, treeId: rootId, reason: binding ? 'replaced' : created ? 'created' : 'reconciled' }) } - return new BuddyTreeJournal(manager, rootId, directory) + return new BuddyTreeJournal(manager, rootId, directory, conversationId, this.#events) } - async read(conversationId: string, cwd: string): Promise { - const binding = this.options.repository.findBinding(conversationId) + async read(conversationId: string, cwd: string) { + this.#events.assertOpen() + const binding = this.#options.repository.findBinding(conversationId) if (!binding) return null const directory = await this.#directory(conversationId) @@ -121,10 +142,12 @@ export class BuddyTreeStore { throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') if (root.customType !== ROOT_TYPE) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') - return new BuddyTreeJournal(manager, binding.rootEntryId, directory) + const snapshot = SessionManager.inMemory(cwd, undefined, [manager.getHeader()!, ...manager.getEntries()]) + return { manager: snapshot, rootId: binding.rootEntryId, validateAncestry: (id: string) => validateAncestry(snapshot, id, binding.rootEntryId) } } async readLegacy(conversationId: string, cwd: string, sessionFile: string | null) { + this.#events.assertOpen() if (!sessionFile) return null return readLegacySession(await this.#directory(conversationId), sessionFile, cwd) @@ -133,26 +156,35 @@ export class BuddyTreeStore { async #directory(conversationId: string) { if (!/^[A-Z0-9][\w-]{0,127}$/i.test(conversationId)) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') - const directory = join(this.options.conversationsDirectory, conversationId, 'session') + const directory = join(this.#options.conversationsDirectory, conversationId, 'session') await mkdir(directory, { recursive: true, mode: 0o700 }) const canonical = await realpath(directory) - if (!containsCanonicalPath(await realpath(this.options.conversationsDirectory), canonical)) + if (!containsCanonicalPath(await realpath(this.#options.conversationsDirectory), canonical)) throw new BuddyAgentRunError('CONVERSATION_BINDING_MISMATCH') return canonical } + + dispose() { + this.#events.dispose() + } } export class BuddyTreeJournal { readonly #checkpoints = new Map() + readonly #events: BuddyTreeEvents + readonly #conversationId: string + #failed = false readonly manager: SessionManager readonly rootId: string readonly directory: string - constructor(manager: SessionManager, rootId: string, directory: string) { + constructor(manager: SessionManager, rootId: string, directory: string, conversationId: string, events: BuddyTreeEvents) { this.manager = manager this.rootId = rootId this.directory = directory + this.#conversationId = conversationId + this.#events = events this.#index() } @@ -163,25 +195,48 @@ export class BuddyTreeJournal { return id } - appendCheckpoint(run: RunRecord, position: TreeCheckpoint['position'], parentId = this.manager.getLeafId()!, legacy = false) { + appendCheckpoint(run: RunRecord, position: TreeCheckpoint['position'], parentId = this.manager.getLeafId()!, recovery?: Extract['recovery']) { + this.#assertWritable() + const existing = this.findCheckpoint(run.id, position) + if (existing) + return existing + const operationId = randomUUID() const previousLeaf = this.manager.getLeafId()! - this.manager.branch(parentId) - const id = this.manager.appendCustomEntry(CHECKPOINT_TYPE, { runId: run.id, branchId: run.branchId, position, legacy } satisfies TreeCheckpoint) - syncSession(this.manager) - this.manager.branch(previousLeaf) - this.#checkpoints.set(`${run.id}:${position}`, id) - return id + try { + this.manager.branch(parentId) + const id = this.manager.appendCustomEntry(CHECKPOINT_TYPE, { runId: run.id, branchId: run.branchId, position, legacy: recovery?.source === 'legacy' || recovery?.source === 'product_history' } satisfies TreeCheckpoint) + syncSession(this.manager) + this.#checkpoints.set(`${run.id}:${position}`, id) + this.#events.commit({ kind: 'checkpoint.committed', operationId, conversationId: this.#conversationId, treeId: this.rootId, runId: run.id, branchId: run.branchId, checkpointId: id, position, ...(recovery ? { recovery } : {}) }) + return id + } + catch (error) { + this.#failed = true + this.#events.fail(this.#conversationId, 'checkpoint', error, operationId) + throw toBuddySessionStorageError(error) ?? error + } + finally { + this.manager.branch(previousLeaf) + } } - appendRecoveredMessages(run: RunRecord, position: TreeCheckpoint['position'], messages: readonly Parameters[0][]) { + appendRecoveredMessages(run: RunRecord, position: TreeCheckpoint['position'], messages: readonly Parameters[0][], recovery: { missingAttachmentCount: number, recoveredImageCount: number }) { + this.#assertWritable() + const existing = this.findCheckpoint(run.id, position) + if (existing) + return existing const previousLeaf = this.manager.getLeafId()! this.manager.branch(this.rootId) try { for (const message of messages) this.manager.appendMessage(message) - return this.appendCheckpoint(run, position, this.manager.getLeafId()!, true) + return this.appendCheckpoint(run, position, this.manager.getLeafId()!, { source: 'product_history', ...recovery }) } catch (error) { + if (!this.#failed) { + this.#failed = true + this.#events.fail(this.#conversationId, 'checkpoint', error) + } throw toBuddySessionStorageError(error) ?? error } finally { @@ -189,19 +244,26 @@ export class BuddyTreeJournal { } } - appendEntries(entries: readonly FileEntry[]) { + appendEntries(run: RunRecord, entries: readonly FileEntry[]) { + this.#assertWritable() if (!entries.length) return const previousLeaf = this.manager.getLeafId()! const sessionFile = this.manager.getSessionFile()! + const operationId = randomUUID() + let stage: 'import' | 'import_index' = 'import' try { appendFileSync(sessionFile, `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`) syncSession(this.manager) + this.#events.commit({ kind: 'entries.imported', operationId, conversationId: this.#conversationId, treeId: this.rootId, runId: run.id, branchId: run.branchId, entryCount: entries.length }) + stage = 'import_index' this.manager.setSessionFile(sessionFile) this.manager.branch(previousLeaf) this.#index() } catch (error) { + this.#failed = true + this.#events.fail(this.#conversationId, stage, error, operationId) throw toBuddySessionStorageError(error) ?? error } } @@ -227,6 +289,12 @@ export class BuddyTreeJournal { this.#checkpoints.set(`${checkpoint.runId}:${checkpoint.position}`, entry.id) } } + + #assertWritable() { + this.#events.assertOpen() + if (this.#failed) + throw new BuddyAgentRunError('SESSION_STORAGE_UNAVAILABLE') + } } export function readCheckpoint(entry: SessionEntry): TreeCheckpoint | null { diff --git a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts index e8ba7b17..19fe817b 100644 --- a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts +++ b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts @@ -1,12 +1,13 @@ import type { AssistantMessage } from '@earendil-works/pi-ai' import type { RunRecord } from '../../../../storage/runRecord' import type { BuddySessionRecoveryService } from '../../recovery/BuddySessionRecoveryService' +import type { BuddyTreeCommit, BuddyTreeFailure } from '../BuddyTreeEvents' import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InMemoryCredentialStore } from '@earendil-works/pi-ai' import { ModelRuntime, SessionManager } from '@earendil-works/pi-coding-agent' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createConversationRepository } from '../../../../storage/conversationRepository' import { createConversationTreeRepository } from '../../../../storage/conversationTreeRepository' import { openBuddyDatabase } from '../../../../storage/database' @@ -22,6 +23,54 @@ afterEach(async () => { }) describe('native conversation tree', () => { + it('retains a durable file commit when binding fails and reconciles without recreating the file', async () => { + const fixture = await createFixture() + const commits: BuddyTreeCommit[] = [] + const failures: BuddyTreeFailure[] = [] + fixture.tree.onDidCommit(event => commits.push(event)) + fixture.tree.onDidFail(event => failures.push(event)) + vi.spyOn(fixture.repository, 'bind').mockImplementationOnce(() => { + throw new Error('Fixture SQL binding failure') + }) + const run = fixture.run('first', 'b0', 'q1') + await expect(fixture.open(run)).rejects.toThrow('Fixture SQL binding failure') + expect(commits.map(event => event.kind)).toEqual(['file.created']) + expect(failures[0]).toMatchObject({ operationId: commits[0]!.operationId, stage: 'binding' }) + expect(fixture.repository.findBinding('conversation')).toBeUndefined() + const file = join(fixture.root, 'conversations/conversation/session/tree.jsonl') + const initial = await readFile(file, 'utf8') + const opened = await fixture.open(run) + expect(commits.map(event => event.kind)).toEqual(['file.created', 'binding.committed']) + expect(commits[1]).toMatchObject({ reason: 'reconciled', treeId: commits[0]!.treeId }) + expect((await readFile(file, 'utf8')).startsWith(initial)).toBe(true) + expect(Reflect.set(commits[0]!, 'treeId', 'changed')).toBe(false) + await opened.session.shutdown('quit') + }) + + it('gives snapshots independent memory managers without new persistent commits', async () => { + const fixture = await createFixture() + const commits: BuddyTreeCommit[] = [] + fixture.tree.onDidCommit(event => commits.push(event)) + const first = fixture.run('first', 'b0', 'q1') + const opened = await fixture.open(first) + await opened.cursor.begin(opened.session.session, first.id) + opened.cursor.manager.appendMessage(user('q1', 'original question')) + opened.cursor.manager.appendMessage(assistant([{ type: 'text', text: 'original answer' }])) + opened.cursor.finish() + opened.cursor.finish() + const file = opened.session.piSessionFile + const saved = await readFile(file, 'utf8') + const count = commits.length + expect(commits.filter(event => event.kind === 'checkpoint.committed').map(event => event.position)).toEqual(['before', 'after']) + const snapshot = await fixture.tree.snapshot('conversation', 'b0', fixture.root) + expect(snapshot?.getSessionFile()).toBeUndefined() + snapshot!.appendMessage(user('private-preview', 'only in preview')) + expect(await readFile(file, 'utf8')).toBe(saved) + expect(JSON.stringify((await fixture.tree.snapshot('conversation', 'b0', fixture.root))!.getEntries())).not.toContain('only in preview') + expect(commits).toHaveLength(count) + await opened.session.shutdown('quit') + }) + it('durably preserves the journal through checkpoints and reopening', async () => { const fixture = await createFixture() const first = fixture.run('first', 'b0', 'q1') @@ -144,6 +193,8 @@ describe('native conversation tree', () => { it('imports native context edits and tool results without rewriting the source journal', async () => { const fixture = await createFixture() + const commits: BuddyTreeCommit[] = [] + fixture.tree.onDidCommit(event => commits.push(event)) const old = fixture.run('old', 'b0', 'q1') const legacy = await createIsolatedBuddySession(fixture.sessionOptions('b0')) const manager = legacy.session.sessionManager @@ -177,7 +228,13 @@ describe('native conversation tree', () => { expect(importedBytes).toContain('OMITTED_ATTEMPT') const reloaded = SessionManager.open(importedFile) expect(reloaded.buildSessionContext().messages).toEqual(opened.session.session.messages) + expect(commits.filter(event => event.kind === 'entries.imported')).toHaveLength(1) + expect(commits.find(event => event.kind === 'checkpoint.committed' && event.runId === old.id)) + .toMatchObject({ recovery: { source: 'legacy' } }) await opened.session.shutdown('quit') + const reopened = await fixture.open(next) + expect(commits.filter(event => event.kind === 'entries.imported')).toHaveLength(1) + await reopened.session.shutdown('quit') }) it('reports an unavailable journal directory before starting a session', async () => { const fixture = await createFixture() @@ -211,6 +268,8 @@ describe('native conversation tree', () => { return { messages: [{ role: 'user', content: 'Recovered question', timestamp: Date.now() }, assistant([{ type: 'text', text: 'Recovered answer' }])], missingAttachmentIds: ['unavailable-image'], recoveredImageCount: 0 } }) const first = fixture.run('first', 'b0', 'q1') + const commits: BuddyTreeCommit[] = [] + fixture.tree.onDidCommit(event => commits.push(event)) const opened = await fixture.open(first) const originalFile = opened.session.piSessionFile fixture.runs.bindSession(first.id, originalFile) @@ -226,6 +285,10 @@ describe('native conversation tree', () => { expect(JSON.stringify(recovered.session.session.messages)).toContain('Recovered answer') expect(recovered.cursor.recoveredFromProductHistory).toBe(true) expect(recovered.cursor.recoveryDegradation?.missingAttachmentIds).toEqual(['unavailable-image']) + expect(commits.filter(event => event.kind === 'binding.committed').map(event => event.reason)).toEqual(['created', 'replaced']) + expect(commits.find(event => event.kind === 'checkpoint.committed' && event.runId === first.id)) + .toMatchObject({ recovery: { source: 'product_history', missingAttachmentCount: 1, recoveredImageCount: 0 } }) + expect(Reflect.set(recovered.cursor.recoveryDegradation!, 'recoveredImageCount', 9)).toBe(false) } finally { await recovered.session.shutdown('quit') @@ -240,10 +303,17 @@ describe('native conversation tree', () => { return { messages: [assistant([{ type: 'text', text: 'Recovered answer' }])], missingAttachmentIds: [], recoveredImageCount: 0 } }) const first = fixture.run('first', 'b0', 'q1') + const commits: BuddyTreeCommit[] = [] + const failures: BuddyTreeFailure[] = [] + fixture.tree.onDidCommit(event => commits.push(event)) + fixture.tree.onDidFail(event => failures.push(event)) fixture.complete(first, 'answer-1') await expect(fixture.open(fixture.run('next', 'b0', 'q2'))) .rejects .toMatchObject({ code: 'SESSION_STORAGE_UNAVAILABLE' }) + expect(commits.map(event => event.kind)).toEqual(['file.created', 'binding.committed']) + expect(failures).toHaveLength(1) + expect(failures[0]).toMatchObject({ stage: 'checkpoint' }) }) }) @@ -261,21 +331,24 @@ async function createFixture(recovery?: BuddySessionRecoveryService['create']) { let time = 0 const now = () => new Date(Date.UTC(2026, 8, 9, 0, 0, ++time)).toISOString() conversations.create({ id: 'conversation', branchId: 'b0', spaceId: null, title: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: now() }) + const repository = createConversationTreeRepository(database) const tree = new BuddyConversationTree({ conversations, runs, conversationsDirectory: join(root, 'conversations'), - repository: createConversationTreeRepository(database), + repository, recovery: { create: recovery ?? (async () => { throw new Error('Healthy native context must not be reconstructed') }), }, }) + cleanups.push(() => tree.dispose()) const sessionOptions = (branchId: string) => ({ agentDir: join(root, 'agent'), branchId, canonicalRoot: root, conversationId: 'conversation', conversationsDirectory: join(root, 'conversations'), cwd: root, approvalPolicy: 'policy' as const, executionProfile: 'workspace_write' as const, inProcessExtensions: [], model, modelRuntime, resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'test' } }) return { root, tree, + repository, runs, sessionOptions, branch(id: string, messageId: string) { diff --git a/apps/buddy/service/src/approvals/ApprovalService.ts b/apps/buddy/service/src/approvals/ApprovalService.ts index e388da3f..8fb0eb9b 100644 --- a/apps/buddy/service/src/approvals/ApprovalService.ts +++ b/apps/buddy/service/src/approvals/ApprovalService.ts @@ -16,6 +16,9 @@ import type { } from '../storage/approvalRepository' import type { ApprovalAuthorizationKeys, ApprovalAuthorizationOverride } from './approvalAuthorization' import { randomUUID } from 'node:crypto' +import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { createApprovalReviewPayload } from '../../../shared/permissions/approvalReviewPayload' import { approvalReuseScopes, createApprovalAuthorizationKeys } from './approvalAuthorization' @@ -58,9 +61,23 @@ export interface ApprovalServiceOptions { eventLog: { append: (input: AppendBuddyRunEventInput) => Promise } onExpired?: (runId: string) => Promise | void repository: ApprovalRepository + onObserverError?: (error: unknown) => void } +type ApprovalCancellationReason = 'cancelled' | 'expired' | 'recovery' | 'shutdown' + +export type ApprovalLifecycleFact = Readonly< + | { kind: 'request.queued', requestId: string, runId: string, toolCallId: string } + | { kind: 'request.released', requestId: string, runId: string, toolCallId: string, reason: 'started' | 'cancelled' | 'shutdown' } + | { kind: 'waiter.started', approvalId: string, runId: string, toolCallId: string } + | { kind: 'waiter.released', approvalId: string, runId: string, toolCallId: string, reason: ApprovalCancellationReason | 'approved' | 'denied' | 'request_failed', persistence: 'committed' | 'failed', durationMs: number } + | { kind: 'authorization.established', approvalId: string, runId: string, scope: ApprovalReuseScope } + | { kind: 'authorization.cleared', runId: string, reason: 'run_cancelled' | 'run_settled' | 'shutdown', count: number } + | { kind: 'cancellation.failed', approvalId: string, runId: string, toolCallId: string, reason: ApprovalCancellationReason, errorCode: string } +> + interface ApprovalWaiter { + startedAt: number authorizationSignal: AbortSignal authorizationKeys: ApprovalAuthorizationKeys reuseScopes: ReadonlySet @@ -85,23 +102,48 @@ export class ApprovalService { readonly #waiters = new Map() readonly #queues = new Map>() readonly #runLifetimes = new Map void>() + readonly #cancellations = new Set>() + readonly #lifecycle: Emitter + readonly onDidChange: Emitter['event'] + #stopping = false + #disposePromise: Promise | null = null constructor(options: ApprovalServiceOptions) { this.#approvalTimeoutMs = options.approvalTimeoutMs ?? APPROVAL_WAIT_TIMEOUT_MS this.#eventLog = options.eventLog this.#onExpired = options.onExpired ?? (() => {}) this.#repository = options.repository + this.#lifecycle = new Emitter(options.onObserverError ?? (() => {})) + this.onDidChange = this.#lifecycle.event } async request(input: ApprovalRequest): Promise { + if (this.#stopping || input.signal.aborted) + throw new ApprovalCancelledError() + input = { ...input } + const requestId = randomUUID() const previous = this.#queues.get(input.runId) ?? Promise.resolve() let started = false + let released = false + const release = (reason: 'started' | 'cancelled' | 'shutdown') => { + if (released) + return + released = true + input.signal.removeEventListener('abort', abort) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'request.released', requestId, runId: input.runId, toolCallId: input.toolCallId, reason })) + } + function abort() { + release('cancelled') + } const pending = previous.then(() => { started = true + release(input.signal.aborted ? 'cancelled' : this.#stopping ? 'shutdown' : 'started') return this.#request(input) }) const settled = pending.then(() => {}, () => {}) this.#queues.set(input.runId, settled) + input.signal.addEventListener('abort', abort, { once: true }) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'request.queued', requestId, runId: input.runId, toolCallId: input.toolCallId })) void settled.then(() => { if (this.#queues.get(input.runId) === settled) this.#queues.delete(input.runId) @@ -110,7 +152,7 @@ export class ApprovalService { } async #request(input: ApprovalRequest): Promise { - if (input.signal.aborted) + if (this.#stopping || input.signal.aborted) throw new ApprovalCancelledError() const authorizationKeys = createApprovalAuthorizationKeys(input, input.reuse) const reuseScopes = approvalReuseScopes(authorizationKeys, input.reuseScopes) @@ -156,11 +198,12 @@ export class ApprovalService { summary: input.summary, toolCallId: input.toolCallId, } - const abort = () => void this.#cancel(approval).catch(() => {}) - const expire = () => void this.#cancel(approval, true).catch(() => {}) + const abort = () => this.#scheduleCancellation(approval, 'cancelled') + const expire = () => this.#scheduleCancellation(approval, 'expired') let timer: ReturnType | null = null const decision = new Promise((resolve, reject) => { this.#waiters.set(approval.id, { + startedAt: Date.now(), authorizationSignal: input.runSignal ?? input.signal, authorizationKeys, cleanup: () => { @@ -176,10 +219,11 @@ export class ApprovalService { }) void decision.catch(() => {}) input.signal.addEventListener('abort', abort, { once: true }) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'waiter.started', approvalId: approval.id, runId: approval.runId, toolCallId: approval.toolCallId })) try { await this.#appendRequested(approval) this.#requireApproval(approval.id) - if (!input.signal.aborted) { + if (!this.#stopping && !input.signal.aborted && this.#waiters.has(approval.id)) { timer = setTimeout(expire, this.#approvalTimeoutMs) timer.unref?.() } @@ -187,28 +231,32 @@ export class ApprovalService { catch (error) { const waiter = this.#waiters.get(approval.id) waiter?.reject(asError(error)) - waiter?.cleanup() - this.#waiters.delete(approval.id) + this.#releaseWaiter(approval, 'request_failed', 'failed') throw error } - if (input.signal.aborted) { - await this.#cancel(approval) + if (this.#stopping || input.signal.aborted) { + await this.#cancel(approval, this.#stopping ? 'shutdown' : 'cancelled') return decision } return decision } async resolve(input: ApprovalResolution): Promise { - if (this.#resolving.has(input.id)) + if (this.#stopping || this.#resolving.has(input.id)) throw new ApprovalResolutionError() return this.#trackResolution(input.id, this.#resolvePending(input)) } - clearRunAuthorizations(runId: string): void { + clearRunAuthorizations(runId: string, reason: 'run_cancelled' | 'run_settled' | 'shutdown' = 'run_settled'): void { this.#runLifetimes.get(runId)?.() this.#runLifetimes.delete(runId) - for (const authorizations of this.#authorizations.values()) + let count = 0 + for (const authorizations of this.#authorizations.values()) { + count += authorizations.get(runId)?.size ?? 0 authorizations.delete(runId) + } + if (count) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'authorization.cleared', runId, reason, count })) } async #resolvePending(input: ApprovalResolution): Promise { @@ -230,7 +278,7 @@ export class ApprovalService { const approval = this.#requireApproval(input.id) if (approval.status !== decision) throw new ApprovalResolutionError() - if (approvedScope && waiter && !waiter.signal.aborted && !waiter.authorizationSignal.aborted) { + if (approvedScope && waiter && !this.#stopping && !waiter.signal.aborted && !waiter.authorizationSignal.aborted) { this.#storeAuthorization( approvedScope, pending.runId, @@ -238,10 +286,11 @@ export class ApprovalService { pending.id, ) if (!this.#runLifetimes.has(pending.runId)) { - const clear = () => this.clearRunAuthorizations(pending.runId) + const clear = () => this.clearRunAuthorizations(pending.runId, 'run_cancelled') waiter.authorizationSignal.addEventListener('abort', clear, { once: true }) this.#runLifetimes.set(pending.runId, () => waiter.authorizationSignal.removeEventListener('abort', clear)) } + this.#lifecycle.fire(copyEventSnapshot({ kind: 'authorization.established', approvalId: pending.id, runId: pending.runId, scope: approvedScope })) } waiter?.resolve({ approvalId: pending.id, @@ -251,37 +300,46 @@ export class ApprovalService { ? 'approved_once' : 'denied', }) - waiter?.cleanup() - this.#waiters.delete(input.id) + this.#releaseWaiter(pending, decision, 'committed') return approval } - async cancelPendingApprovals(): Promise { + async cancelPendingApprovals(reason: 'recovery' | 'shutdown' = 'recovery'): Promise { let cancelled = 0 for (const approval of this.#repository.listPending()) { - await this.#cancel(approval) + await this.#cancel(approval, reason) cancelled += 1 } return cancelled } - async #cancel(approval: ApprovalRecord, expired = false): Promise { + async #cancel(approval: ApprovalRecord, reason: ApprovalCancellationReason): Promise { const resolving = this.#resolving.get(approval.id) if (resolving) { await resolving.catch(() => {}) - return this.#cancel(approval, expired) + return this.#cancel(approval, reason) } const pending = this.#repository.findById(approval.id) if (!pending || pending.status !== 'pending') return - await this.#trackResolution(approval.id, this.#cancelPending(pending, expired)) - if (expired) + await this.#trackResolution(approval.id, this.#cancelPending(pending, reason)) + if (reason === 'expired') await this.#onExpired(approval.runId) } - async #cancelPending(approval: ApprovalRecord, expired: boolean): Promise { + #scheduleCancellation(approval: ApprovalRecord, reason: ApprovalCancellationReason): void { + const operation = this.#cancel(approval, reason) + this.#cancellations.add(operation) + void operation.then(() => this.#cancellations.delete(operation), (error) => { + this.#cancellations.delete(operation) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'cancellation.failed', approvalId: approval.id, runId: approval.runId, toolCallId: approval.toolCallId, reason, errorCode: readDiagnosticErrorCode(error) })) + }) + } + + async #cancelPending(approval: ApprovalRecord, reason: ApprovalCancellationReason): Promise { const resolvedAt = new Date().toISOString() const waiter = this.#waiters.get(approval.id) + let persistence: 'committed' | 'failed' = 'failed' try { await this.#appendResolved({ ...approval, @@ -289,16 +347,53 @@ export class ApprovalService { status: 'cancelled', resolution: 'cancelled', }) - waiter?.reject(expired ? new ApprovalExpiredError() : new ApprovalCancelledError()) + persistence = 'committed' + waiter?.reject(reason === 'expired' ? new ApprovalExpiredError() : new ApprovalCancelledError()) } catch (error) { waiter?.reject(asError(error)) throw error } finally { - waiter?.cleanup() - this.#waiters.delete(approval.id) + this.#releaseWaiter(approval, reason, persistence) + } + } + + dispose(): Promise { + if (this.#disposePromise) + return this.#disposePromise + this.#stopping = true + this.#disposePromise = this.#dispose() + return this.#disposePromise + } + + async #dispose(): Promise { + const failures: unknown[] = [] + try { + const cancelled = await Promise.allSettled(this.#repository.listPending().map(approval => this.#cancel(approval, 'shutdown'))) + failures.push(...cancelled.flatMap(result => result.status === 'rejected' ? [result.reason] : [])) + await Promise.allSettled(this.#queues.values()) + await Promise.allSettled(this.#resolving.values()) + const cancellations = await Promise.allSettled(this.#cancellations) + failures.push(...cancellations.flatMap(result => result.status === 'rejected' ? [result.reason] : [])) + } + finally { + const runIds = new Set([...this.#authorizations.values()].flatMap(authorizations => [...authorizations.keys()])) + for (const runId of runIds) + this.clearRunAuthorizations(runId, 'shutdown') + this.#lifecycle.dispose() } + if (failures.length) + throw new AggregateError(failures, 'Lexora Buddy approval shutdown could not persist every cancellation') + } + + #releaseWaiter(approval: ApprovalRecord, reason: Extract['reason'], persistence: 'committed' | 'failed'): void { + const waiter = this.#waiters.get(approval.id) + if (!waiter) + return + waiter.cleanup() + this.#waiters.delete(approval.id) + this.#lifecycle.fire(copyEventSnapshot({ kind: 'waiter.released', approvalId: approval.id, runId: approval.runId, toolCallId: approval.toolCallId, reason, persistence, durationMs: Math.max(0, Date.now() - waiter.startedAt) })) } #trackResolution(id: string, operation: Promise): Promise { diff --git a/apps/buddy/service/src/approvals/__tests__/ApprovalScope.spec.ts b/apps/buddy/service/src/approvals/__tests__/ApprovalScope.spec.ts index 211eba1f..46271b8b 100644 --- a/apps/buddy/service/src/approvals/__tests__/ApprovalScope.spec.ts +++ b/apps/buddy/service/src/approvals/__tests__/ApprovalScope.spec.ts @@ -4,11 +4,76 @@ import type { ApprovalRepository, ApprovalStatus, } from '../../storage/approvalRepository' +import type { ApprovalLifecycleFact } from '../ApprovalService' import { describe, expect, it, vi } from 'vitest' import { ApprovalService } from '../ApprovalService' describe('turn approval scope', () => { + it('releases an aborted queued request without installing a waiter or persisting an approval', async () => { + const records = new Map() + const repository = createRepository(records) + const service = new ApprovalService({ eventLog: { append: input => appendApprovalEvent(records, input) }, repository }) + const facts: ApprovalLifecycleFact[] = [] + service.onDidChange(event => facts.push(event)) + const first = service.request(shellRequest('run-queue', 'tool-first', 'pnpm test', new AbortController().signal)) + await vi.waitFor(() => expect(repository.listPending()).toHaveLength(1)) + const controller = new AbortController() + const queued = service.request(shellRequest('run-queue', 'tool-queued', 'pnpm lint', controller.signal)) + controller.abort() + await expect(queued).rejects.toMatchObject({ code: 'APPROVAL_CANCELLED' }) + expect(facts.filter(event => 'toolCallId' in event && event.toolCallId === 'tool-queued')).toEqual([ + expect.objectContaining({ kind: 'request.queued' }), + expect.objectContaining({ kind: 'request.released', reason: 'cancelled' }), + ]) + await service.resolve({ id: repository.listPending()[0]!.id, decision: 'denied' }) + await first + await service.dispose() + expect(records.size).toBe(1) + expect(facts.filter(event => event.kind === 'waiter.started')).toHaveLength(1) + }) + + it('reports released waiters separately from failed durable cancellation and drains shutdown', async () => { + const records = new Map() + const repository = createRepository(records) + const service = new ApprovalService({ + eventLog: { append: input => input.type === 'approval.resolved' ? Promise.reject(new Error('Store unavailable')) : appendApprovalEvent(records, input) }, + repository, + }) + const facts: ApprovalLifecycleFact[] = [] + service.onDidChange(event => facts.push(event)) + const first = service.request(shellRequest('run-stop', 'tool-active', 'pnpm test', new AbortController().signal)) + const decisions = Promise.allSettled([first, service.request(shellRequest('run-stop', 'tool-queued', 'pnpm lint', new AbortController().signal))]) + await vi.waitFor(() => expect(repository.listPending()).toHaveLength(1)) + await expect(service.dispose()).rejects.toBeInstanceOf(AggregateError) + expect((await decisions).map(result => result.status)).toEqual(['rejected', 'rejected']) + expect(repository.listPending()).toHaveLength(1) + expect(facts).toContainEqual(expect.objectContaining({ kind: 'waiter.released', reason: 'shutdown', persistence: 'failed' })) + expect(facts).toContainEqual(expect.objectContaining({ kind: 'request.released', toolCallId: 'tool-queued', reason: 'shutdown' })) + await expect(service.request(shellRequest('run-stop', 'tool-late', 'pnpm test', new AbortController().signal))).rejects.toMatchObject({ code: 'APPROVAL_CANCELLED' }) + }) + + it('drains the required expiry cancellation after the waiter has been released', async () => { + const records = new Map() + const repository = createRepository(records) + const expired = Promise.withResolvers() + const onExpired = vi.fn(() => expired.promise) + const service = new ApprovalService({ approvalTimeoutMs: 5, onExpired, eventLog: { append: input => appendApprovalEvent(records, input) }, repository }) + const facts: ApprovalLifecycleFact[] = [] + service.onDidChange(event => facts.push(event)) + await expect(service.request(shellRequest('run-expiry', 'tool-expiry', 'pnpm test', new AbortController().signal))).rejects.toMatchObject({ code: 'AUTOMATION_APPROVAL_EXPIRED' }) + expect(facts).toContainEqual(expect.objectContaining({ kind: 'waiter.released', reason: 'expired', persistence: 'committed' })) + let stopped = false + const stopping = service.dispose().then(() => { + stopped = true + }) + await vi.waitFor(() => expect(onExpired).toHaveBeenCalledOnce()) + expect(stopped).toBe(false) + expired.resolve() + await stopping + expect(stopped).toBe(true) + }) + it('reuses approval for later approvable operations in the same turn only', async () => { const records = new Map() const repository = createRepository(records) @@ -16,6 +81,8 @@ describe('turn approval scope', () => { eventLog: { append: input => appendApprovalEvent(records, input) }, repository, }) + const facts: ApprovalLifecycleFact[] = [] + service.onDidChange(event => facts.push(event)) const controller = new AbortController() const initial = service.request({ reuseScopes: ['operation', 'source', 'turn'], @@ -72,6 +139,13 @@ describe('turn approval scope', () => { }) service.clearRunAuthorizations('run-1') + service.clearRunAuthorizations('run-1') + expect(facts.filter(event => event.kind === 'authorization.established')).toEqual([ + expect.objectContaining({ runId: 'run-1', scope: 'turn', approvalId: sourceApprovalId }), + ]) + expect(facts.filter(event => event.kind === 'authorization.cleared')).toEqual([ + { kind: 'authorization.cleared', runId: 'run-1', reason: 'run_settled', count: 1 }, + ]) const clearedTurn = service.request({ reuseScopes: ['operation', 'source', 'turn'], arguments: { command: 'python process.py' }, diff --git a/apps/buddy/service/src/artifacts/ArtifactService.ts b/apps/buddy/service/src/artifacts/ArtifactService.ts index 3364f8a4..f6c3a4e1 100644 --- a/apps/buddy/service/src/artifacts/ArtifactService.ts +++ b/apps/buddy/service/src/artifacts/ArtifactService.ts @@ -16,6 +16,8 @@ import { sep, } from 'node:path' import { relativeCanonicalPath } from '../../../platform/filesystem/filePaths' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { resolveGrantedPath } from '../directories/resolveGrantedPath' export const BUDDY_ARTIFACT_COUNT_LIMIT = 512 @@ -35,20 +37,139 @@ export interface ConversationArtifactLocation { resource: ArtifactResource } +export interface ArtifactBatchReceipt { + readonly operationId: string + readonly conversationId: string + readonly cause: 'presentation' | 'generated' | 'recovery' + readonly requested: number + readonly written: number + readonly unconfirmedWrites: number + readonly artifactIds: readonly string[] + readonly stage: 'validation' | 'file' | 'catalogue' + readonly outcome: 'pending' | 'completed' | 'partial' | 'failed' +} + +export interface ArtifactEvent { + readonly sourceId: string + readonly revision: number + readonly kind: 'file-written' | 'catalogue-committed' | 'batch-settled' + readonly receipt: ArtifactBatchReceipt + readonly artifactId?: string + readonly created?: boolean + readonly errorCode?: 'ARTIFACT_PUBLICATION_FAILED' +} + +interface ArtifactBatch { + operationId: string + conversationId: string + cause: ArtifactBatchReceipt['cause'] + requested: number + written: number + unconfirmedWrites: number + artifactIds: string[] + stage: ArtifactBatchReceipt['stage'] +} + +interface PresentOutputsInput { + conversationId: string + cwd: string + grants: readonly DirectoryGrant[] + paths: readonly string[] + sourceArtifactId?: string | null +} + +interface GeneratedImagesInput { + conversationId: string + cwd: string + grants: readonly DirectoryGrant[] + images: readonly GeneratedArtifactImage[] + outputPath: string + sourceArtifactId: string | null +} + export class ArtifactService { readonly #repository: ArtifactRepository - - constructor(options: { repository: ArtifactRepository }) { + readonly #sourceId = randomUUID() + readonly #changes: Emitter + readonly onDidChange + readonly #pending = new Set>() + #revision = 0 + #disposed = false + + constructor(options: { repository: ArtifactRepository, onListenerError?: (error: unknown) => void }) { this.#repository = options.repository + this.#changes = new Emitter(options.onListenerError ?? (() => console.error('ARTIFACT_OBSERVER_FAILED'))) + this.onDidChange = this.#changes.event + } + + presentOutputs(input: PresentOutputsInput): Promise { + const request = copyEventSnapshot({ conversationId: input.conversationId, cwd: input.cwd, grants: input.grants, paths: input.paths, sourceArtifactId: input.sourceArtifactId }) + return this.#batch(request.conversationId, 'presentation', request.paths.length, batch => this.#presentOutputs(request, batch)) + } + + registerGeneratedImages(input: GeneratedImagesInput): Promise { + const request = { ...copyEventSnapshot({ conversationId: input.conversationId, cwd: input.cwd, grants: input.grants, outputPath: input.outputPath, sourceArtifactId: input.sourceArtifactId }), images: input.images.map(image => ({ bytes: Uint8Array.from(image.bytes), mimeType: image.mimeType })) } + return this.#batch(request.conversationId, 'generated', request.images.length, batch => this.#registerGeneratedImages(request, batch)) + } + + recoverLegacyRecords(records: readonly ArtifactRecord[]): Promise { + const request = copyEventSnapshot(records) + if (!request.length) + return Promise.resolve(0) + return this.#batch(request[0]!.conversationId, 'recovery', request.length, async (batch) => { + for (const record of request) { + if (record.conversationId !== batch.conversationId) + throw new ArtifactError('VALIDATION_FAILED') + const existing = this.#repository.findById(record.id) + const path = this.#repository.findByCurrentPath(record.conversationId, record.currentPath) + if (existing || path) { + if (existing?.conversationId !== record.conversationId || existing.currentPath !== record.currentPath || path?.id !== record.id) + throw new ArtifactError('VALIDATION_FAILED') + continue + } + batch.stage = 'catalogue' + this.#repository.save(record) + batch.artifactIds.push(record.id) + this.#publish(batch, 'catalogue-committed', { artifactId: record.id, created: true }) + } + return batch.artifactIds.length + }) + } + + async whenIdle(): Promise { + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + async dispose(): Promise { + this.#disposed = true + await this.whenIdle() + this.#changes.dispose() + } + + #batch(conversationId: string, cause: ArtifactBatchReceipt['cause'], requested: number, operation: (batch: ArtifactBatch) => Promise): Promise { + if (this.#disposed) + return Promise.reject(new ArtifactError('ARTIFACT_SERVICE_STOPPED')) + const batch: ArtifactBatch = { conversationId, cause, requested, operationId: randomUUID(), written: 0, unconfirmedWrites: 0, artifactIds: [], stage: 'validation' } + const pending = Promise.resolve().then(() => operation(batch)).then((result) => { + this.#publish(batch, 'batch-settled', {}, 'completed') + return result + }, (error: unknown) => { + const outcome = batch.written || batch.unconfirmedWrites || batch.artifactIds.length ? 'partial' : 'failed' + const receipt = this.#publish(batch, 'batch-settled', { errorCode: 'ARTIFACT_PUBLICATION_FAILED' }, outcome) + throw new ArtifactPublicationError(error, receipt) + }).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + return pending + } + + #publish(batch: ArtifactBatch, kind: ArtifactEvent['kind'], details: Pick = {}, outcome: ArtifactBatchReceipt['outcome'] = 'pending'): ArtifactBatchReceipt { + const receipt = copyEventSnapshot({ ...batch, outcome }) + this.#changes.fire(copyEventSnapshot({ sourceId: this.#sourceId, revision: ++this.#revision, kind, receipt, ...details })) + return receipt } - async presentOutputs(input: { - conversationId: string - cwd: string - grants: readonly DirectoryGrant[] - paths: readonly string[] - sourceArtifactId?: string | null - }): Promise { + async #presentOutputs(input: PresentOutputsInput, batch: ArtifactBatch): Promise { if ( input.paths.length === 0 || input.paths.length > BUDDY_ARTIFACT_COUNT_LIMIT @@ -92,7 +213,8 @@ export class ArtifactService { ? existing.sourceArtifactId : sourceArtifactId || existing?.sourceArtifactId || null const now = new Date().toISOString() - return this.#repository.save({ + batch.stage = 'catalogue' + const record = this.#repository.save({ conversationId: input.conversationId, createdAt: existing?.createdAt ?? now, currentPath: candidate.location.canonicalPath, @@ -107,17 +229,13 @@ export class ArtifactService { sourceArtifactId: normalizedSourceArtifactId, updatedAt: now, }) + batch.artifactIds.push(record.id) + this.#publish(batch, 'catalogue-committed', { artifactId: record.id, created: !existing }) + return record }) } - async registerGeneratedImages(input: { - conversationId: string - cwd: string - grants: readonly DirectoryGrant[] - images: readonly GeneratedArtifactImage[] - outputPath: string - sourceArtifactId: string | null - }): Promise { + async #registerGeneratedImages(input: GeneratedImagesInput, batch: ArtifactBatch): Promise { if ( input.images.length === 0 || input.images.length > BUDDY_ARTIFACT_COUNT_LIMIT @@ -153,16 +271,22 @@ export class ArtifactService { const location = await resolveArtifactLocation(input.grants, outputPath, 'create') if (isSensitivePath(location.relativePath)) throw new ArtifactError('ARTIFACT_SENSITIVE_PATH') + batch.stage = 'file' await mkdir(dirname(location.canonicalPath), { mode: 0o700, recursive: true }) + batch.unconfirmedWrites += 1 await writeFile(location.canonicalPath, image.bytes, { mode: 0o600 }) + batch.unconfirmedWrites -= 1 + batch.written += 1 + this.#publish(batch, 'file-written') } - return this.presentOutputs({ + batch.stage = 'catalogue' + return this.#presentOutputs({ conversationId: input.conversationId, cwd: input.cwd, grants: input.grants, paths: outputPaths, sourceArtifactId: input.sourceArtifactId, - }) + }, batch) } listConversationArtifacts( @@ -313,6 +437,19 @@ export class ArtifactError extends Error { } } +export class ArtifactPublicationError extends ArtifactError { + readonly receipt: ArtifactBatchReceipt + + constructor(error: unknown, receipt: ArtifactBatchReceipt) { + const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(error.code) + ? error.code + : 'ARTIFACT_PUBLICATION_FAILED' + super(code) + this.name = 'ArtifactPublicationError' + this.receipt = copyEventSnapshot(receipt) + } +} + interface ArtifactLocation { canonicalPath: string grant: DirectoryGrant diff --git a/apps/buddy/service/src/artifacts/LegacyArtifactRecovery.ts b/apps/buddy/service/src/artifacts/LegacyArtifactRecovery.ts index a20cb3e5..97e1503c 100644 --- a/apps/buddy/service/src/artifacts/LegacyArtifactRecovery.ts +++ b/apps/buddy/service/src/artifacts/LegacyArtifactRecovery.ts @@ -3,6 +3,7 @@ import type { RunEventReader } from '../events/RunEventPorts' import type { ArtifactRepository } from '../storage/artifactRepository' import type { BuddyDataPaths } from '../storage/BuddyDataPaths' import type { ConversationRepository } from '../storage/conversationRepository' +import type { ArtifactService } from './ArtifactService' import { readdir, realpath, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import { buddyRunOutputPayloadSchema } from '../../../shared/runs/runOutput' @@ -14,7 +15,8 @@ const legacyOutputTools = new Set([ ]) export async function reconcileLegacyArtifactOutputs(options: { - artifacts: Pick + artifacts: Pick + catalogue: Pick conversations: Pick eventLog: Pick paths: Pick @@ -30,6 +32,7 @@ export async function reconcileLegacyArtifactOutputs(options: { continue recovered += await recoverConversationArtifacts({ artifacts: options.artifacts, + catalogue: options.catalogue, conversationId: conversation.id, eventLog: options.eventLog, paths: options.paths, @@ -48,7 +51,8 @@ async function listDirectories(directory: string) { } async function recoverConversationArtifacts(options: { - artifacts: Pick + artifacts: Pick + catalogue: Pick conversationId: string eventLog: Pick paths: Pick @@ -63,23 +67,23 @@ async function recoverConversationArtifacts(options: { if ( outputs.length === 0 || new Set(outputs.map(output => output.artifactId)).size !== outputs.length - || outputs.some(output => options.artifacts.findById(output.artifactId) !== null) ) { return 0 } - if ( - files.items.length !== outputs.length - || files.items.some(file => ( - options.artifacts.findByCurrentPath(options.conversationId, file.path) !== null - )) - ) { + if (files.items.length !== outputs.length) return 0 + for (const [index, output] of outputs.entries()) { + const file = files.items[index]! + const existing = options.artifacts.findById(output.artifactId) + const path = options.artifacts.findByCurrentPath(options.conversationId, file.path) + if ((existing && (existing.conversationId !== options.conversationId || existing.currentPath !== file.path)) || (path && path.id !== output.artifactId)) + return 0 } - for (const [index, output] of outputs.entries()) { + return options.catalogue.recoverLegacyRecords(outputs.map((output, index) => { const file = files.items[index]! - options.artifacts.save({ + return { conversationId: options.conversationId, createdAt: output.createdAt, currentPath: file.path, @@ -93,9 +97,8 @@ async function recoverConversationArtifacts(options: { sizeBytes: file.sizeBytes, sourceArtifactId: null, updatedAt: output.createdAt, - }) - } - return outputs.length + } + })) } function listLegacyOutputs(events: readonly BuddyRunEvent[]): Array<{ diff --git a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts index 231f8214..104c9e72 100644 --- a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts @@ -1,11 +1,12 @@ import type { DatabaseSync } from 'node:sqlite' import type { DirectoryGrant } from '../../directories/resolveGrantedPath' +import type { ArtifactEvent } from '../ArtifactService' import { Buffer } from 'node:buffer' import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { prepareTestTurnRequest } from '../../storage/__tests__/composerDraftTestFixture' import { createArtifactRepository } from '../../storage/artifactRepository' import { createConversationRepository } from '../../storage/conversationRepository' @@ -22,6 +23,59 @@ afterEach(async () => { }) describe('artifactService', () => { + it('retains written files and committed catalogue rows when a later row fails', async () => { + const fixture = await createFixture() + let saves = 0 + let listenerFailures = 0 + const service = new ArtifactService({ repository: { ...fixture.repository, save(record) { + if (++saves === 2) + throw new Error('private storage detail') + return fixture.repository.save(record) + } }, onListenerError: () => listenerFailures++ }) + const events: ArtifactEvent[] = [] + service.onDidChange(() => { + throw new Error('observer detail') + }) + service.onDidChange(event => events.push(event)) + await expect(service.registerGeneratedImages({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, outputPath: 'result.png', sourceArtifactId: null, images: [{ bytes: Uint8Array.of(1), mimeType: 'image/png' }, { bytes: Uint8Array.of(2), mimeType: 'image/png' }] })).rejects.toMatchObject({ receipt: { outcome: 'partial', written: 2, unconfirmedWrites: 0, stage: 'catalogue', artifactIds: [expect.any(String)] } }) + expect(events.map(event => event.kind)).toEqual(['file-written', 'file-written', 'catalogue-committed', 'batch-settled']) + expect(events.map(event => event.revision)).toEqual([1, 2, 3, 4]) + expect(listenerFailures).toBe(4) + const receipt = events.at(-1)!.receipt + expect(Object.isFrozen(receipt.artifactIds)).toBe(true) + expect(fixture.repository.listForConversation('conversation-1').map(record => record.id)).toEqual(receipt.artifactIds) + expect(await readFile(join(fixture.workspace, 'result.png'))).toEqual(Buffer.from([1])) + expect(await readFile(join(fixture.workspace, 'result-2.png'))).toEqual(Buffer.from([2])) + expect(JSON.stringify(events)).not.toContain(fixture.workspace) + expect(JSON.stringify(events)).not.toContain('private storage detail') + await service.dispose() + }) + + it('reports an unsuccessful file write separately from confirmed writes', async () => { + const fixture = await createFixture() + await mkdir(join(fixture.workspace, 'result-2.png')) + await expect(fixture.service.registerGeneratedImages({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, outputPath: 'result.png', sourceArtifactId: null, images: [{ bytes: Uint8Array.of(1), mimeType: 'image/png' }, { bytes: Uint8Array.of(2), mimeType: 'image/png' }] })).rejects.toMatchObject({ receipt: { outcome: 'partial', written: 1, unconfirmedWrites: 1, artifactIds: [], stage: 'file' } }) + expect(await readFile(join(fixture.workspace, 'result.png'))).toEqual(Buffer.from([1])) + expect(fixture.repository.listForConversation('conversation-1')).toEqual([]) + }) + + it('isolates accepted bytes and grants and drains their publication before disposal', async () => { + const fixture = await createFixture() + const bytes = Uint8Array.of(1, 2, 3) + const events: ArtifactEvent[] = [] + fixture.service.onDidChange(event => events.push(event)) + const pending = fixture.service.registerGeneratedImages({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, outputPath: 'result.png', sourceArtifactId: null, images: [{ bytes, mimeType: 'image/png' }] }) + bytes.fill(9) + fixture.grants[0]!.canonicalRoot = fixture.root + fixture.grants.length = 0 + const stopping = fixture.service.dispose() + const [artifact] = await pending + await stopping + expect(await readFile(artifact!.currentPath)).toEqual(Buffer.from([1, 2, 3])) + expect(events.at(-1)).toMatchObject({ kind: 'batch-settled', receipt: { outcome: 'completed', written: 1, artifactIds: [artifact!.id] } }) + await expect(fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: [], paths: [artifact!.currentPath] })).rejects.toMatchObject({ code: 'ARTIFACT_SERVICE_STOPPED' }) + }) + it('presents explicitly selected files and directories without inferring their contents', async () => { const fixture = await createFixture() const directoryPath = join(fixture.workspace, 'site') diff --git a/apps/buddy/service/src/artifacts/__tests__/LegacyArtifactRecovery.spec.ts b/apps/buddy/service/src/artifacts/__tests__/LegacyArtifactRecovery.spec.ts index b15917b7..3fa697c4 100644 --- a/apps/buddy/service/src/artifacts/__tests__/LegacyArtifactRecovery.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/LegacyArtifactRecovery.spec.ts @@ -1,13 +1,15 @@ import type { DatabaseSync } from 'node:sqlite' +import type { ArtifactEvent } from '../ArtifactService' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createArtifactRepository } from '../../storage/artifactRepository' import { BuddyDataPaths } from '../../storage/BuddyDataPaths' import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' +import { ArtifactService } from '../ArtifactService' import { reconcileLegacyArtifactOutputs } from '../LegacyArtifactRecovery' const databases: DatabaseSync[] = [] @@ -40,6 +42,16 @@ describe('legacy artifact output recovery', () => { await writeFile(join(directory, 'first.png'), Uint8Array.of(1, 2, 3)) await writeFile(join(directory, 'second.html'), '

Recovered

') const repository = createArtifactRepository(database) + let failNext = true + const events: ArtifactEvent[] = [] + const catalogue = new ArtifactService({ repository: { ...repository, save(record) { + if (record.id === 'artifact-file' && failNext) { + failNext = false + throw new Error('catalogue unavailable') + } + return repository.save(record) + } } }) + catalogue.onDidChange(event => events.push(event)) const eventLog = { listForConversation: () => [{ createdAt: '2026-09-04T00:00:01.000Z', @@ -64,12 +76,12 @@ describe('legacy artifact output recovery', () => { }], } - await expect(reconcileLegacyArtifactOutputs({ - artifacts: repository, - conversations, - eventLog, - paths, - })).resolves.toBe(2) + await expect(reconcileLegacyArtifactOutputs({ artifacts: repository, catalogue, conversations, eventLog, paths })).rejects.toMatchObject({ receipt: { cause: 'recovery', outcome: 'partial', artifactIds: ['artifact-image'], written: 0 } }) + expect(repository.findById('artifact-image')).not.toBeNull() + expect(repository.findById('artifact-file')).toBeNull() + await expect(reconcileLegacyArtifactOutputs({ artifacts: repository, catalogue, conversations, eventLog, paths })).resolves.toBe(1) + expect(events.filter(event => event.kind === 'catalogue-committed').map(event => event.artifactId)).toEqual(['artifact-image', 'artifact-file']) + expect(events.every(event => event.receipt.cause === 'recovery')).toBe(true) expect(repository.findById('artifact-image')).toMatchObject({ currentPath: join(directory, 'first.png'), mimeType: 'image/png', @@ -80,6 +92,7 @@ describe('legacy artifact output recovery', () => { }) await expect(reconcileLegacyArtifactOutputs({ artifacts: repository, + catalogue: new ArtifactService({ repository }), conversations, eventLog, paths, @@ -109,6 +122,7 @@ describe('legacy artifact output recovery', () => { await expect(reconcileLegacyArtifactOutputs({ artifacts: repository, + catalogue: new ArtifactService({ repository }), conversations, eventLog: { listForConversation: () => [{ diff --git a/apps/buddy/service/src/attachments/AttachmentService.ts b/apps/buddy/service/src/attachments/AttachmentService.ts index c42d2b70..92ebdf2b 100644 --- a/apps/buddy/service/src/attachments/AttachmentService.ts +++ b/apps/buddy/service/src/attachments/AttachmentService.ts @@ -7,6 +7,7 @@ import type { InputModel } from '../providers/modelCapabilities' import type { AttachmentRecord, AttachmentRepository } from '../storage/attachmentRepository' import type { BuddyDataPaths } from '../storage/BuddyDataPaths' import type { AttachmentDocumentReference, AttachmentFileInput } from './AttachmentDocumentReference' +import type { AttachmentChange } from './attachmentEvents' import type { AttachmentImageReference } from './AttachmentImageReference' import type { PreparedAttachmentImage } from './AttachmentImageStore' import type { AttachmentToolWorkspace } from './AttachmentToolWorkspace' @@ -23,6 +24,8 @@ import { getAttachmentKind, } from '../../../shared/conversation/attachmentPolicy' import { projectBuddyUserContent } from '../../../shared/conversation/buddyUserContentProjection' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { AttachmentImageStore } from './AttachmentImageStore' import { getAttachmentLabels } from './attachmentLabels' import { hasDocumentSignature } from './validateDocumentBytes' @@ -92,12 +95,24 @@ export interface MessageAttachmentBinding { } export interface PreparedMessageAttachments { - bindings: readonly MessageAttachmentBinding[] + bindings: readonly Readonly[] + commit: () => Promise + rollback: () => Promise +} + +export interface PreparedAttachmentUploads { + readonly records: readonly Readonly[] commit: () => Promise rollback: () => Promise } export class AttachmentService { + readonly #changes = new Emitter(() => console.error('ATTACHMENT_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #leasedPaths = new Map() + #revision = 0 + #storageTail = Promise.resolve() + #stopping = false readonly #images = new AttachmentImageStore() readonly #paths: BuddyDataPaths readonly #readFile: AttachmentFileReader @@ -109,7 +124,98 @@ export class AttachmentService { this.#repository = options.repository } - async registerFiles( + registerFiles(draftId: string, paths: readonly string[], limits = { count: BUDDY_ATTACHMENT_COUNT_LIMIT, errorCode: 'VALIDATION_FAILED', totalBytes: BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT }): Promise { + const owned = [...paths] + const ownedLimits = { ...limits } + return this.#storage(() => this.#registerFiles(draftId, owned, ownedLimits)) + } + + registerUploads(draftId: string, uploads: readonly BuddyAttachmentUpload[]): Promise { + const owned = uploads.map(upload => ({ ...upload, bytes: Uint8Array.from(upload.bytes) })) + return this.#storage(() => this.#registerUploads(draftId, owned)) + } + + prepareUploads(draftId: string, uploads: readonly BuddyAttachmentUpload[]): Promise { + const owned = uploads.map(upload => ({ ...upload, bytes: Uint8Array.from(upload.bytes) })) + return this.#storage(async () => { + const records = await this.#registerUploads(draftId, owned) + for (const record of records) { + const path = normalize(record.storedPath) + this.#leasedPaths.set(path, (this.#leasedPaths.get(path) ?? 0) + 1) + } + let settlement: { outcome: 'commit' | 'rollback', promise: Promise } | undefined + const settle = (outcome: 'commit' | 'rollback'): Promise => { + if (settlement) { + return settlement.outcome === outcome + ? settlement.promise + : Promise.reject(new Error('ATTACHMENT_PREPARATION_SETTLED')) + } + const promise = this.#storage(async () => { + for (const record of records) { + const path = normalize(record.storedPath) + const remaining = (this.#leasedPaths.get(path) ?? 1) - 1 + if (remaining) + this.#leasedPaths.set(path, remaining) + else this.#leasedPaths.delete(path) + } + if (outcome === 'rollback') { + const ownedIds = records.filter((record) => { + const current = this.#repository.findById(record.id) + return current?.draftId === draftId && current.storedPath === record.storedPath + }).map(record => record.id) + await this.#release(ownedIds) + } + }, true) + settlement = { outcome, promise } + return promise + } + return Object.freeze({ records: copyEventSnapshot(records), commit: () => settle('commit'), rollback: () => settle('rollback') }) + }) + } + + prepareMessageAttachments(input: { attachmentIds: readonly string[], conversationId: string, draftId: string, messageId: string }): Promise { + const owned = { ...input, attachmentIds: [...input.attachmentIds] } + return this.#storage(() => this.#prepareMessageAttachments(owned)) + } + + release(ids: readonly string[]): Promise { + const owned = [...ids] + return this.#storage(() => this.#release(owned)) + } + + releaseDraft(draftId: string): Promise { + return this.#storage(() => this.#releaseDraft(draftId)) + } + + reconcileStorage(): Promise { + return this.#storage(() => this.#reconcileStorage()) + } + + async dispose(): Promise { + this.#stopping = true + let tail: Promise + do { + tail = this.#storageTail + await tail + } while (tail !== this.#storageTail) + if (this.#leasedPaths.size) + throw new Error('ATTACHMENT_PREPARATIONS_PENDING') + this.#changes.dispose() + } + + #storage(operation: () => Promise, finishing = false): Promise { + if (this.#stopping && !finishing) + return Promise.reject(new AttachmentError('RUNTIME_OFFLINE')) + const result = this.#storageTail.then(operation) + this.#storageTail = result.then(() => {}, () => {}) + return result + } + + #publish(kind: AttachmentChange['kind'], phase: AttachmentChange['phase'], attachmentIds: readonly string[], operationId: string = randomUUID(), count = attachmentIds.length): void { + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, operationId, kind, phase, attachmentIds, count })) + } + + async #registerFiles( draftId: string, paths: readonly string[], limits = { @@ -133,6 +239,7 @@ export class AttachmentService { validateTotalBytes(sources.map(source => source.metadata.size), limits.totalBytes, limits.errorCode) const directory = this.#paths.draftAttachments(draftId) await mkdir(directory, { mode: 0o700, recursive: true }) + const operationId = randomUUID() const records: AttachmentRecord[] = [] const attempted: AttachmentRecord[] = [] try { @@ -153,20 +260,25 @@ export class AttachmentService { } attempted.push(record) await publishFile(sourcePath, storedPath) + this.#publish('file-published', 'import', [record.id], operationId) if (isDocumentMimeType(mimeType) && !hasDocumentSignature(mimeType, await this.#readFile(storedPath))) throw new AttachmentError('ATTACHMENT_INVALID') this.#repository.create(record) + this.#publish('registered', 'import', [record.id], operationId) records.push(record) } } catch (error) { - await this.#rollbackRegistration(attempted) + try { + await this.#rollbackRegistration(attempted, operationId) + } + catch (cleanup) { throw new AggregateError([error, cleanup], 'Attachment import and cleanup failed') } throw error } return records } - async registerUploads( + async #registerUploads( draftId: string, uploads: readonly BuddyAttachmentUpload[], ): Promise { @@ -181,6 +293,7 @@ export class AttachmentService { validateTotalBytes(sources.map(source => source.bytes.byteLength)) const directory = this.#paths.draftAttachments(draftId) await mkdir(directory, { mode: 0o700, recursive: true }) + const operationId = randomUUID() const records: AttachmentRecord[] = [] const attempted: AttachmentRecord[] = [] try { @@ -202,18 +315,23 @@ export class AttachmentService { } attempted.push(record) await publishBytes(source.bytes, storedPath) + this.#publish('file-published', 'import', [record.id], operationId) this.#repository.create(record) + this.#publish('registered', 'import', [record.id], operationId) records.push(record) } } catch (error) { - await this.#rollbackRegistration(attempted) + try { + await this.#rollbackRegistration(attempted, operationId) + } + catch (cleanup) { throw new AggregateError([error, cleanup], 'Attachment import and cleanup failed') } throw error } return records } - async prepareMessageAttachments(input: { + async #prepareMessageAttachments(input: { attachmentIds: readonly string[] conversationId: string draftId: string @@ -234,12 +352,34 @@ export class AttachmentService { } const directory = this.#paths.messageInputs(input.conversationId, input.messageId) await mkdir(directory, { mode: 0o700, recursive: true }) + const operationId = randomUUID() const bindings: MessageAttachmentBinding[] = [] + const attemptedPaths: string[] = [] + const leasedPaths: string[] = [] + const lease = (path: string) => { + const key = normalize(path) + this.#leasedPaths.set(key, (this.#leasedPaths.get(key) ?? 0) + 1) + leasedPaths.push(key) + } + const releaseLeases = () => { + for (const path of leasedPaths.splice(0)) { + const remaining = (this.#leasedPaths.get(path) ?? 1) - 1 + if (remaining) + this.#leasedPaths.set(path, remaining) + else this.#leasedPaths.delete(path) + } + } try { for (const record of records) { const id = record.draftId === input.draftId ? record.id : randomUUID() const storedPath = join(directory, `${id}${safeExtension(record.name)}`) + if (this.#leasedPaths.has(normalize(storedPath)) || this.#repository.listAll().some(owned => normalize(owned.storedPath) === normalize(storedPath))) + throw new AttachmentError('VALIDATION_FAILED') + lease(record.storedPath) + lease(storedPath) + attemptedPaths.push(storedPath) await publishFile(record.storedPath, storedPath) + this.#publish('file-published', 'message', [id], operationId) bindings.push({ createdAt: new Date().toISOString(), id, @@ -256,15 +396,51 @@ export class AttachmentService { } } catch (error) { - await removeFiles(bindings.map(binding => binding.storedPath)) + try { + await removeFiles(attemptedPaths) + if (attemptedPaths.length) + this.#publish('cleanup-completed', 'rollback', bindings.map(binding => binding.id), operationId, attemptedPaths.length) + } + catch (cleanup) { + this.#publish('cleanup-failed', 'rollback', bindings.map(binding => binding.id), operationId) + throw new AggregateError([error, cleanup], 'Attachment preparation and cleanup failed') + } + finally { releaseLeases() } throw error } + this.#publish('prepared', 'message', bindings.map(binding => binding.id), operationId) + let settlement: { outcome: 'commit' | 'rollback', promise: Promise } | undefined + const settle = (outcome: 'commit' | 'rollback'): Promise => { + if (settlement) { + return settlement.outcome === outcome + ? settlement.promise + : Promise.reject(new Error('ATTACHMENT_PREPARATION_SETTLED')) + } + const promise = this.#storage(async () => { + try { + const owned = new Set(this.#repository.listAll().map(record => normalize(record.storedPath))) + if (outcome === 'commit' && bindings.some(binding => !owned.has(normalize(binding.storedPath)))) + throw new Error('ATTACHMENT_OWNERSHIP_NOT_COMMITTED') + const candidates = outcome === 'commit' + ? bindings.flatMap(binding => binding.sourceDraftId ? [binding.sourceStoredPath] : []) + : bindings.map(binding => binding.storedPath) + const removable = candidates.filter(path => !owned.has(normalize(path))) + await removeFiles(removable) + this.#publish('cleanup-completed', outcome, bindings.map(binding => binding.id), operationId, removable.length) + } + catch (error) { + this.#publish('cleanup-failed', outcome, bindings.map(binding => binding.id), operationId) + throw error + } + finally { releaseLeases() } + }, true) + settlement = { outcome, promise } + return promise + } return { - bindings, - commit: () => removeFiles(bindings.flatMap( - binding => binding.sourceDraftId ? [binding.sourceStoredPath] : [], - )), - rollback: () => removeFiles(bindings.map(binding => binding.storedPath)), + bindings: copyEventSnapshot(bindings), + commit: () => settle('commit'), + rollback: () => settle('rollback'), } } @@ -277,21 +453,30 @@ export class AttachmentService { return { mimeType: record.mimeType, path: record.storedPath } } - async release(ids: readonly string[]): Promise { + async #release(ids: readonly string[]): Promise { const released: string[] = [] for (const id of ids) { const record = this.#repository.findById(id) if (!record?.draftId) continue - await unlinkAvailableFile(record.storedPath) - if (this.#repository.removeDraft(id)) - released.push(id) + if (this.#leasedPaths.has(normalize(record.storedPath)) || !this.#repository.removeDraft(id)) + continue + released.push(id) + this.#publish('released', 'release', [id]) + try { + await unlinkAvailableFile(record.storedPath) + this.#publish('cleanup-completed', 'release', [id]) + } + catch (error) { + this.#publish('cleanup-failed', 'release', [id]) + throw error + } } return released } - async releaseDraft(draftId: string): Promise { - await this.release(this.#repository.listAll().filter(record => record.draftId === draftId).map(record => record.id)) + async #releaseDraft(draftId: string): Promise { + await this.#release(this.#repository.listAll().filter(record => record.draftId === draftId).map(record => record.id)) const directory = this.#paths.draftAttachments(draftId) for (const path of [directory, dirname(directory)]) { try { @@ -311,7 +496,7 @@ export class AttachmentService { .map(record => record.id)) } - async reconcileStorage(): Promise { + async #reconcileStorage(): Promise { const records = this.#repository.listAll() const ownedPaths = new Set(records.map(record => normalize(record.storedPath))) const invalidAttachmentIds: string[] = [] @@ -338,13 +523,14 @@ export class AttachmentService { let removedOrphanFiles = 0 for (const root of roots) { for (const path of await listFilesRecursively(root)) { - if (ownedPaths.has(normalize(path))) + if (ownedPaths.has(normalize(path)) || this.#leasedPaths.has(normalize(path))) continue await unlinkAvailableFile(path) removedOrphanFiles += 1 } } + this.#publish('reconciled', 'recovery', [...invalidAttachmentIds, ...missingAttachmentIds], randomUUID(), removedOrphanFiles) return { invalidAttachmentIds: invalidAttachmentIds.sort(), missingAttachmentIds: missingAttachmentIds.sort(), @@ -673,14 +859,24 @@ export class AttachmentService { throw new AttachmentError('VALIDATION_FAILED') } - async #rollbackRegistration(records: readonly AttachmentRecord[]): Promise { + async #rollbackRegistration(records: readonly AttachmentRecord[], operationId: string): Promise { + const failures: unknown[] = [] for (const record of records.toReversed()) { - await unlinkAvailableFile(record.storedPath).catch(() => undefined) try { - this.#repository.removeDraft(record.id) + if (this.#repository.removeDraft(record.id)) + this.#publish('released', 'rollback', [record.id], operationId) + if (this.#repository.findById(record.id)) + continue + await unlinkAvailableFile(record.storedPath) + this.#publish('cleanup-completed', 'rollback', [record.id], operationId) + } + catch (error) { + this.#publish('cleanup-failed', 'rollback', [record.id], operationId) + failures.push(error) } - catch {} } + if (failures.length) + throw new AggregateError(failures, 'Attachment rollback cleanup failed') } } diff --git a/apps/buddy/service/src/attachments/ComposerResourceService.ts b/apps/buddy/service/src/attachments/ComposerResourceService.ts index 32c3cac2..39fe3cde 100644 --- a/apps/buddy/service/src/attachments/ComposerResourceService.ts +++ b/apps/buddy/service/src/attachments/ComposerResourceService.ts @@ -28,7 +28,8 @@ import type { ComposerResourceRecord, ComposerResourceRepository } from '../stor import type { ConversationDirectoryGrantRepository } from '../storage/conversationDirectoryGrantRepository' import type { ConversationRepository } from '../storage/conversationRepository' import type { SpaceRepository } from '../storage/spaceRepository' -import type { AttachmentService } from './AttachmentService' +import type { AttachmentService, PreparedAttachmentUploads } from './AttachmentService' +import type { ComposerResourceChange, ComposerResourceChangeReason } from './composerResourceEvents' import { createHash, randomUUID } from 'node:crypto' import { realpath } from 'node:fs/promises' import { dirname, isAbsolute, join, relative } from 'node:path' @@ -36,6 +37,8 @@ import { readBoundedFile } from '../../../platform/filesystem/boundedFile' import { BUDDY_ATTACHMENT_COUNT_LIMIT, BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT, getAttachmentKind } from '../../../shared/conversation/attachmentPolicy' import { getBuddyUserContentResourceIds, readBuddyUserMessageContent } from '../../../shared/conversation/buddyUserContent' import { buddyComposerResourceAcceptSchema, buddyComposerSourceListSchema, buddyComposerSourceSelectSchema, buddyComposerSpaceFileSelectSchema } from '../../../shared/conversation/composerResource' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { buddyRunOutputPayloadSchema } from '../../../shared/runs/runOutput' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { createSensitivePathMatcher } from '../permissions/sensitivePaths' @@ -50,7 +53,7 @@ import { validateResourceBytes } from './validateResourceBytes' export interface ComposerResourceServiceOptions { artifacts?: Pick - attachments: Pick + attachments: Pick conversationGrants?: Pick conversations?: Pick drafts?: Pick @@ -66,7 +69,20 @@ export interface ComposerSourceScope { spaceId: string | null } +export interface PreparedComposerInput { + readonly inputs: readonly BuddyUserMessageResourceSnapshot[] + validate: () => void + commit: () => Promise + rollback: () => Promise +} + export class ComposerResourceService { + readonly #changes = new Emitter(() => console.error('COMPOSER_RESOURCE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Set>() + #revision = 0 + #stopping = false + #disposed = false readonly #attachments: ComposerResourceServiceOptions['attachments'] readonly #artifacts: ComposerResourceServiceOptions['artifacts'] readonly #conversationGrants: ComposerResourceServiceOptions['conversationGrants'] @@ -93,7 +109,11 @@ export class ComposerResourceService { this.#directories = new ComposerDirectorySources(options) } - async accept(input: BuddyComposerResourceAccept): Promise { + accept(input: BuddyComposerResourceAccept): Promise { + return this.#operate(() => this.#accept(input)) + } + + async #accept(input: BuddyComposerResourceAccept): Promise { const parsed = buddyComposerResourceAcceptSchema.parse(input) const incoming = await Promise.all(parsed.resources.map(async (resource) => { if (resource.storage === 'reference') { @@ -103,9 +123,9 @@ export class ComposerResourceService { return { resource: { ...resource, ...normalizeAttachmentMetadata(resource) }, localReference: null } })) try { - return this.#repository.acceptBatch(input.draftId, incoming.map(({ resource, localReference }) => localReference + return this.#commit(parsed.draftId, 'import', () => this.#repository.acceptBatch(parsed.draftId, incoming.map(({ resource, localReference }) => localReference ? { metadata: { ...localReference, sourcePath: localReference.path, resourceId: resource.resourceId }, source: { localReference } } - : { metadata: resource }), new Date().toISOString()).map(toPublicResource) + : { metadata: resource }), new Date().toISOString())).map(toPublicResource) } catch (error) { if (error instanceof ComposerResourceConflictError) @@ -158,78 +178,130 @@ export class ComposerResourceService { return { ...directorySources, files: [...directorySources.files.slice(0, 64), ...conversationFiles.filter(file => file.category === 'history').slice(0, 32), ...conversationFiles.filter(file => file.category === 'artifact').slice(0, 32)] } } - async selectSource( - input: BuddyComposerSourceSelect, - referencedResourceIds: readonly string[] = [], - ): Promise { + selectSource(input: BuddyComposerSourceSelect, referencedResourceIds: readonly string[] = [], reason: ComposerResourceChangeReason = 'selection'): Promise { + const ownedIds = [...referencedResourceIds] + return this.#operate(() => this.#selectSource(input, ownedIds, reason)) + } + + async #selectSource(input: BuddyComposerSourceSelect, referencedResourceIds: readonly string[], reason: ComposerResourceChangeReason): Promise { const parsed = buddyComposerSourceSelectSchema.parse(input) const resolved = await this.#resolveSource(parsed.source) this.#assertCapacity(parsed.draftId, referencedResourceIds, [{ sizeBytes: 'localReference' in resolved.source ? 0 : resolved.metadata.sizeBytes }]) - return toPublicResource(this.#repository.selectSource(parsed.draftId, { + return toPublicResource(this.#commit(parsed.draftId, reason, () => this.#repository.selectSource(parsed.draftId, { ...resolved.metadata, resourceId: parsed.resourceId, - }, resolved.source, new Date().toISOString())) + }, resolved.source, new Date().toISOString()))) } - async selectSpaceFilePath(draftId: string, spaceId: string, path: string): Promise { - const space = requireActiveSpace(this.#spaces?.findById(spaceId) ?? null) - const bindings = [space.primaryDirectory, ...space.additionalDirectories].filter(binding => binding !== null) - const requestedPath = isAbsolute(path) ? path : space.primaryDirectory ? join(space.primaryDirectory.canonicalRoot, path) : null - if (!requestedPath) - throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') - const resolution = await resolveGrantedPath(bindings.map(binding => ({ canonicalRoot: binding.canonicalRoot, grantId: binding.id, kind: 'workspace', root: binding.root })), requestedPath, 'existing') - const binding = bindings.find(binding => binding.id === resolution.grantId)! - return this.selectSpaceFile({ draftId, resourceId: randomUUID(), source: { spaceId, bindingId: binding.id, relativePath: relative(binding.canonicalRoot, resolution.canonicalPath) } }) + async selectSpaceFilePath(draftId: string, spaceId: string, path: string, reason: ComposerResourceChangeReason = 'selection'): Promise { + return this.#operate(async () => { + const space = requireActiveSpace(this.#spaces?.findById(spaceId) ?? null) + const bindings = [space.primaryDirectory, ...space.additionalDirectories].filter(binding => binding !== null) + const requestedPath = isAbsolute(path) ? path : space.primaryDirectory ? join(space.primaryDirectory.canonicalRoot, path) : null + if (!requestedPath) + throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') + const resolution = await resolveGrantedPath(bindings.map(binding => ({ canonicalRoot: binding.canonicalRoot, grantId: binding.id, kind: 'workspace', root: binding.root })), requestedPath, 'existing') + const binding = bindings.find(binding => binding.id === resolution.grantId)! + return this.#selectSource({ draftId, resourceId: randomUUID(), source: { spaceId, bindingId: binding.id, relativePath: relative(binding.canonicalRoot, resolution.canonicalPath) } }, [], reason) + }) } - async resolveInput(draftId: string, content: BuddyUserContentV1, scope: ComposerSourceScope = { branchId: null, conversationId: null, spaceId: null }, model?: Pick): Promise { - const resourceIds = getBuddyUserContentResourceIds(content) - if (resourceIds.length > BUDDY_ATTACHMENT_COUNT_LIMIT) - throw new AttachmentError('VALIDATION_FAILED') - const resources = resourceIds.map((resourceId) => { - const resource = this.#requireOwned({ draftId, resourceId }) - if (resource.state !== 'ready') + async resolveInput(draftId: string, content: BuddyUserContentV1, scope: ComposerSourceScope = { branchId: null, conversationId: null, spaceId: null }, model?: Pick): Promise { + content = copyEventSnapshot(content) + scope = copyEventSnapshot(scope) + model = model ? { ...model, input: [...model.input], fileInputMimeTypes: [...model.fileInputMimeTypes] } : undefined + return this.#operate(async () => { + const resourceIds = getBuddyUserContentResourceIds(content) + if (resourceIds.length > BUDDY_ATTACHMENT_COUNT_LIMIT) throw new AttachmentError('VALIDATION_FAILED') - if (resource.source && 'spaceId' in resource.source && resource.source.spaceId !== scope.spaceId) - throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') - if (resource.source && 'conversationId' in resource.source && resource.source.conversationId !== scope.conversationId) - throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') - return resource - }) - const result: BuddyUserMessageResourceSnapshot[] = [] - const orderedResources = resources.toSorted((left, right) => Number(isNewLocalReference(left)) - Number(isNewLocalReference(right))) - let totalBytes = 0 - for (const resource of orderedResources) { - if (resource.source && 'localReference' in resource.source) { - const snapshot = await this.#resolveLocalInput(resource, resource.source, scope, model, BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT - totalBytes) - result.push(snapshot.input) - totalBytes += snapshot.bytes - continue + const resources = resourceIds.map((resourceId) => { + const resource = this.#requireOwned({ draftId, resourceId }) + if (resource.state !== 'ready') + throw new AttachmentError('VALIDATION_FAILED') + if (resource.source && 'spaceId' in resource.source && resource.source.spaceId !== scope.spaceId) + throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') + if (resource.source && 'conversationId' in resource.source && resource.source.conversationId !== scope.conversationId) + throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') + return resource + }) + const uploads: PreparedAttachmentUploads[] = [] + let settlement: { outcome: 'commit' | 'rollback', promise: Promise } | undefined + const validate = () => { + if (settlement) + throw new Error('COMPOSER_INPUT_SETTLED') + for (const resource of resources) { + const current = this.#repository.findById(resource.resourceId) + if (JSON.stringify(current) !== JSON.stringify(resource)) + throw new AttachmentError('ATTACHMENT_NOT_FOUND') + } } - if (!resource.source) { - totalBytes += resource.sizeBytes - result.push({ attachmentId: resource.attachmentId!, resourceId: resource.resourceId }) - continue + const settle = (outcome: 'commit' | 'rollback') => { + if (settlement) { + return settlement.outcome === outcome + ? settlement.promise + : Promise.reject(new Error('COMPOSER_INPUT_SETTLED')) + } + const promise = (async () => { + const failures: unknown[] = [] + for (const upload of uploads.toReversed()) { + try { + await upload[outcome]() + } + catch (error) { failures.push(error) } + } + if (failures.length) + throw new AggregateError(failures, 'COMPOSER_INPUT_CLEANUP_FAILED') + })() + settlement = { outcome, promise } + return promise } - const resolved = await this.#resolveSourceOrigin(resource.source, scope) - totalBytes += resolved.metadata.sizeBytes - if (totalBytes > BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT) - throw new AttachmentError('VALIDATION_FAILED') - if (resolved.attachmentId) { - result.push({ attachmentId: resolved.attachmentId, resourceId: resource.resourceId }) + try { + const result: BuddyUserMessageResourceSnapshot[] = [] + const orderedResources = resources.toSorted((left, right) => Number(isNewLocalReference(left)) - Number(isNewLocalReference(right))) + let totalBytes = 0 + for (const resource of orderedResources) { + if (resource.source && 'localReference' in resource.source) { + const snapshot = await this.#resolveLocalInput(resource, resource.source, scope, model, BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT - totalBytes, uploads) + result.push(snapshot.input) + totalBytes += snapshot.bytes + continue + } + if (!resource.source) { + totalBytes += resource.sizeBytes + result.push({ attachmentId: resource.attachmentId!, resourceId: resource.resourceId }) + continue + } + const resolved = await this.#resolveSourceOrigin(resource.source, scope) + totalBytes += resolved.metadata.sizeBytes + if (totalBytes > BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT) + throw new AttachmentError('VALIDATION_FAILED') + if (resolved.attachmentId) { + result.push({ attachmentId: resolved.attachmentId, resourceId: resource.resourceId }) + } + else { + await validateResourceBytes(resolved.metadata, resolved.bytes) + const upload = await this.#attachments.prepareUploads(draftId, [{ ...resolved.metadata, bytes: Uint8Array.from(resolved.bytes) }]) + uploads.push(upload) + const [attachment] = upload.records + if (!attachment) + throw new AttachmentError('ATTACHMENT_NOT_FOUND') + result.push({ attachmentId: attachment.id, resourceId: resource.resourceId }) + } + } + if (totalBytes > BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT) + throw new AttachmentError('VALIDATION_FAILED') + const snapshots = new Map(result.map(snapshot => [snapshot.resourceId, snapshot])) + validate() + return Object.freeze({ inputs: copyEventSnapshot(resourceIds.map(resourceId => snapshots.get(resourceId)!)), validate, commit: () => settle('commit'), rollback: () => settle('rollback') }) } - else { - await validateResourceBytes(resolved.metadata, resolved.bytes) - const [attachment] = await this.#attachments.registerUploads(draftId, [{ ...resolved.metadata, bytes: Uint8Array.from(resolved.bytes) }]) - if (!attachment) - throw new AttachmentError('ATTACHMENT_NOT_FOUND') - result.push({ attachmentId: attachment.id, resourceId: resource.resourceId }) + catch (error) { + try { + await settle('rollback') + } + catch (cleanup) { throw new AggregateError([error, cleanup], 'COMPOSER_INPUT_PREPARATION_FAILED') } + throw error } - } - if (totalBytes > BUDDY_ATTACHMENT_TOTAL_BYTES_LIMIT) - throw new AttachmentError('VALIDATION_FAILED') - const snapshots = new Map(result.map(snapshot => [snapshot.resourceId, snapshot])) - return resourceIds.map(resourceId => snapshots.get(resourceId)!) + }) } #resolveScope(input: BuddyComposerSourceList): ComposerSourceScope { @@ -360,7 +432,7 @@ export class ComposerResourceService { return snapshot } - async #resolveLocalInput(resource: ComposerResourceRecord, source: BuddyLocalResourceOrigin, scope: ComposerSourceScope, model: Pick | undefined, remainingBytes: number): Promise<{ bytes: number, input: BuddyUserMessageResourceSnapshot }> { + async #resolveLocalInput(resource: ComposerResourceRecord, source: BuddyLocalResourceOrigin, scope: ComposerSourceScope, model: Pick | undefined, remainingBytes: number, uploads?: PreparedAttachmentUploads[]): Promise<{ bytes: number, input: BuddyUserMessageResourceSnapshot }> { const origin = source.origin if (origin && 'conversationId' in origin && (origin.conversationId !== scope.conversationId || origin.branchId !== scope.branchId)) throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') @@ -388,7 +460,7 @@ export class ComposerResourceService { const nativeSupported = model && (localReference.mimeType.startsWith('image/') ? model.input.includes('image') : (model.fileInputMimeTypes as readonly string[]).includes(localReference.mimeType)) - if (localReference.kind !== 'file' || !nativeSupported || localReference.sizeBytes > remainingBytes) + if (!uploads || localReference.kind !== 'file' || !nativeSupported || localReference.sizeBytes > remainingBytes) return { bytes: 0, input } try { normalizeAttachmentMetadata(localReference) @@ -407,7 +479,9 @@ export class ComposerResourceService { return { bytes: 0, input } throw error } - const [attachment] = await this.#attachments.registerUploads(resource.draftId, [{ ...localReference, sourcePath: localReference.path, bytes: Uint8Array.from(bytes) }]) + const upload = await this.#attachments.prepareUploads(resource.draftId, [{ ...localReference, sourcePath: localReference.path, bytes: Uint8Array.from(bytes) }]) + uploads.push(upload) + const [attachment] = upload.records if (!attachment) throw new AttachmentError('ATTACHMENT_NOT_FOUND') return { bytes: localReference.sizeBytes, input: { ...input, attachmentId: attachment.id } } @@ -524,64 +598,91 @@ export class ComposerResourceService { } async complete(input: BuddyComposerResourceComplete): Promise { - const resource = this.#requireOwned(input) - if (resource.source) - throw new AttachmentError('VALIDATION_FAILED') - const contentHash = createHash('sha256').update(input.bytes).digest('hex') - if (resource.state === 'ready') { - if (resource.contentHash !== contentHash) + input = { ...input, bytes: Uint8Array.from(input.bytes) } + return this.#operate(async () => { + const resource = this.#requireOwned(input) + if (resource.source) throw new AttachmentError('VALIDATION_FAILED') - return toPublicResource(resource) - } - if (resource.state !== 'importing' || this.#importing.has(input.resourceId)) - throw new AttachmentError('VALIDATION_FAILED') - - this.#importing.add(input.resourceId) - try { - await validateResourceBytes(resource, input.bytes) - const [attachment] = await this.#attachments.registerUploads(input.draftId, [{ - bytes: input.bytes, - mimeType: resource.mimeType, - name: resource.name, - nameSource: resource.nameSource, - sourcePath: resource.sourcePath, - }]) - if (!attachment) - throw new AttachmentError('ATTACHMENT_NOT_FOUND') - if (!this.#repository.finish({ - attachmentId: attachment.id, - contentHash, - draftId: input.draftId, - now: new Date().toISOString(), - resourceId: input.resourceId, - })) { - await this.#attachments.release([attachment.id]) + const contentHash = createHash('sha256').update(input.bytes).digest('hex') + if (resource.state === 'ready') { + if (resource.contentHash !== contentHash) + throw new AttachmentError('VALIDATION_FAILED') + return toPublicResource(resource) + } + if (resource.state !== 'importing' || this.#importing.has(input.resourceId)) throw new AttachmentError('VALIDATION_FAILED') + + this.#importing.add(input.resourceId) + let upload: PreparedAttachmentUploads | undefined + try { + await validateResourceBytes(resource, input.bytes) + upload = await this.#attachments.prepareUploads(input.draftId, [{ + bytes: input.bytes, + mimeType: resource.mimeType, + name: resource.name, + nameSource: resource.nameSource, + sourcePath: resource.sourcePath, + }]) + const [attachment] = upload.records + if (!attachment) + throw new AttachmentError('ATTACHMENT_NOT_FOUND') + if (!this.#commit(input.draftId, 'import', () => this.#repository.finish({ + attachmentId: attachment.id, + contentHash, + draftId: input.draftId, + now: new Date().toISOString(), + resourceId: input.resourceId, + }))) { + throw new AttachmentError('VALIDATION_FAILED') + } + const ready = toPublicResource(this.#requireOwned(input)) + await upload.commit() + return ready } - return toPublicResource(this.#requireOwned(input)) - } - catch (error) { - const failed = this.fail(input) - if (error instanceof AttachmentError && ['ATTACHMENT_INVALID', 'ATTACHMENT_UNSUPPORTED', 'ATTACHMENT_TOO_LARGE'].includes(error.code)) - throw error - return failed - } - finally { - this.#importing.delete(input.resourceId) - } + catch (error) { + if (upload) { + let current: ComposerResourceRecord | null + try { + current = this.#repository.findById(input.resourceId) + } + catch (readError) { + await upload.commit() + throw new AggregateError([error, readError], 'COMPOSER_IMPORT_OWNERSHIP_UNKNOWN') + } + if (current?.attachmentId === upload.records[0]?.id) { + await upload.commit() + return toPublicResource(current) + } + await upload.rollback() + } + const failed = this.#fail(input) + if (error instanceof AttachmentError && ['ATTACHMENT_INVALID', 'ATTACHMENT_UNSUPPORTED', 'ATTACHMENT_TOO_LARGE'].includes(error.code)) + throw error + return failed + } + finally { + this.#importing.delete(input.resourceId) + } + }) } fail(input: BuddyComposerResourceTarget): BuddyComposerResource { + this.#assertAccepting() + return this.#fail(input) + } + + #fail(input: BuddyComposerResourceTarget): BuddyComposerResource { this.#requireOwned(input) - this.#repository.fail(input.draftId, input.resourceId, 'IMPORT_FAILED', new Date().toISOString()) + this.#commit(input.draftId, 'import', () => this.#repository.fail(input.draftId, input.resourceId, 'IMPORT_FAILED', new Date().toISOString())) return toPublicResource(this.#requireOwned(input)) } retry(input: BuddyComposerResourceTarget): BuddyComposerResource { + this.#assertAccepting() this.#requireOwned(input) if (this.#importing.has(input.resourceId)) throw new AttachmentError('VALIDATION_FAILED') - this.#repository.retry(input.draftId, input.resourceId, new Date().toISOString()) + this.#commit(input.draftId, 'import', () => this.#repository.retry(input.draftId, input.resourceId, new Date().toISOString())) return toPublicResource(this.#requireOwned(input)) } @@ -590,60 +691,124 @@ export class ComposerResourceService { paths: readonly string[] referencedResourceIds?: readonly string[] }): Promise { - const { draftId, paths, referencedResourceIds = [] } = input - if (!paths.length) - return [] - const capacity = this.#remainingCapacity(draftId, referencedResourceIds) - if (paths.length > capacity.count) - throw new AttachmentError('ATTACHMENT_LIMIT_EXCEEDED') - const locals = await Promise.all(paths.map(path => inspectLocalResource(path))) - return this.accept({ - draftId, - resources: locals.map(resource => ({ - mimeType: resource.mimeType, - name: resource.name, - sourcePath: resource.path, - storage: 'reference', - resourceId: randomUUID(), - sizeBytes: resource.sizeBytes, - })), + return this.#operate(async () => { + const { draftId, paths, referencedResourceIds = [] } = input + if (!paths.length) + return [] + const capacity = this.#remainingCapacity(draftId, referencedResourceIds) + if (paths.length > capacity.count) + throw new AttachmentError('ATTACHMENT_LIMIT_EXCEEDED') + const locals = await Promise.all(paths.map(path => inspectLocalResource(path))) + return this.#accept({ + draftId, + resources: locals.map(resource => ({ + mimeType: resource.mimeType, + name: resource.name, + sourcePath: resource.path, + storage: 'reference', + resourceId: randomUUID(), + sizeBytes: resource.sizeBytes, + })), + }) }) } recoverInterruptedImports(unavailableAttachmentIds: readonly string[] = []): void { - this.#repository.interruptImports(new Date().toISOString()) - this.#repository.failUnavailableAttachments(unavailableAttachmentIds, new Date().toISOString()) + this.#assertAccepting() + this.#commit(null, 'recovery', () => { + this.#repository.interruptImports(new Date().toISOString()) + this.#repository.failUnavailableAttachments(unavailableAttachmentIds, new Date().toISOString()) + }) } async cleanupDrafts(now = Date.now()): Promise { - const cutoff = new Date(now - DRAFT_ATTACHMENT_RETENTION_MS).toISOString() - const staleResources = this.#repository.listAll().filter(resource => resource.createdAt < cutoff) - const retainedAttachmentIds = new Set() - const removableByDraft = new Map() - - for (const resource of staleResources) { - const draft = this.#drafts?.findById(resource.draftId) - const referenced = draft - ? getBuddyUserContentResourceIds(draft.content).includes(resource.resourceId) - : false - if (referenced) { - if (resource.attachmentId) - retainedAttachmentIds.add(resource.attachmentId) - continue + return this.#operate(async () => { + const cutoff = new Date(now - DRAFT_ATTACHMENT_RETENTION_MS).toISOString() + const staleResources = this.#repository.listAll().filter(resource => resource.createdAt < cutoff) + const retainedAttachmentIds = new Set() + const removableByDraft = new Map() + + for (const resource of staleResources) { + const draft = this.#drafts?.findById(resource.draftId) + const referenced = draft + ? getBuddyUserContentResourceIds(draft.content).includes(resource.resourceId) + : false + if (referenced) { + if (resource.attachmentId) + retainedAttachmentIds.add(resource.attachmentId) + continue + } + const ids = removableByDraft.get(resource.draftId) ?? [] + ids.push(resource.resourceId) + removableByDraft.set(resource.draftId, ids) } - const ids = removableByDraft.get(resource.draftId) ?? [] - ids.push(resource.resourceId) - removableByDraft.set(resource.draftId, ids) - } - for (const [draftId, resourceIds] of removableByDraft) - this.#repository.remove(draftId, resourceIds) - return this.#attachments.cleanupDrafts(now, retainedAttachmentIds) + for (const [draftId, resourceIds] of removableByDraft) + this.#commit(draftId, 'cleanup', () => this.#repository.remove(draftId, resourceIds)) + return this.#attachments.cleanupDrafts(now, retainedAttachmentIds) + }) } async discard(draftId: string): Promise { - this.#repository.remove(draftId, this.#repository.listForDraft(draftId).map(resource => resource.resourceId)) - await this.#attachments.releaseDraft(draftId) + return this.#operate(async () => { + this.#commit(draftId, 'discard', () => this.#repository.remove(draftId, this.#repository.listForDraft(draftId).map(resource => resource.resourceId))) + await this.#attachments.releaseDraft(draftId) + }) + } + + async dispose(): Promise { + this.#stopping = true + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + this.#disposed = true + this.#changes.dispose() + } + + #commit(draftId: string | null, reason: ComposerResourceChangeReason, work: () => T): T { + if (this.#disposed) + throw new AttachmentError('RUNTIME_UNAVAILABLE') + const read = () => draftId === null ? this.#repository.listAll() : this.#repository.listForDraft(draftId) + const before = new Map(read().map(resource => [resource.resourceId, resource])) + try { + return work() + } + finally { + const after = new Map(read().map(resource => [resource.resourceId, resource])) + const resources: ComposerResourceChange['resources'][number][] = [] + for (const id of new Set([...before.keys(), ...after.keys()])) { + const previous = before.get(id) + const current = after.get(id) + const semantic = (record: ComposerResourceRecord | undefined) => { + if (!record) + return null + const { updatedAt: _updated, ...value } = record + return value + } + if (JSON.stringify(semantic(previous)) === JSON.stringify(semantic(current))) + continue + resources.push({ resourceId: id, draftId: (current ?? previous)!.draftId, kind: !previous ? 'created' : !current ? 'removed' : 'changed', state: current?.state ?? null }) + } + if (resources.length) + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, operationId: randomUUID(), reason, resources })) + } + } + + #assertAccepting(): void { + if (this.#stopping) + throw new AttachmentError('RUNTIME_UNAVAILABLE') + } + + #operate(work: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new AttachmentError('RUNTIME_UNAVAILABLE')) + const pending = Promise.withResolvers() + this.#pending.add(pending.promise) + try { + void Promise.resolve(work()).then(pending.resolve, pending.reject) + } + catch (error) { pending.reject(error) } + void pending.promise.then(() => this.#pending.delete(pending.promise), () => this.#pending.delete(pending.promise)) + return pending.promise } #requireOwned(input: BuddyComposerResourceTarget): ComposerResourceRecord { diff --git a/apps/buddy/service/src/attachments/__tests__/AttachmentLifecycle.spec.ts b/apps/buddy/service/src/attachments/__tests__/AttachmentLifecycle.spec.ts new file mode 100644 index 00000000..dc75f872 --- /dev/null +++ b/apps/buddy/service/src/attachments/__tests__/AttachmentLifecycle.spec.ts @@ -0,0 +1,112 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { AttachmentChange } from '../attachmentEvents' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createAttachmentRepository } from '../../storage/attachmentRepository' +import { BuddyDataPaths } from '../../storage/BuddyDataPaths' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { AttachmentService } from '../AttachmentService' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function setup() { + const root = await mkdtemp(join(tmpdir(), 'lexora-attachment-lifecycle-')) + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const repository = createAttachmentRepository(database) + const service = new AttachmentService({ paths: new BuddyDataPaths(root), repository }) + const events: AttachmentChange[] = [] + service.onDidChange(event => events.push(event)) + cleanups.push(async () => { + await service.dispose() + database.close() + await rm(root, { recursive: true, force: true }) + }) + const [record] = await service.registerUploads('draft-1', [{ bytes: Uint8Array.of(1, 2, 3), mimeType: 'text/plain', name: 'source.txt' }]) + return { database, repository, service, events, record: record! } +} + +function createConversation(database: DatabaseSync) { + createConversationRepository(database).create({ id: 'conversation-1', branchId: 'branch-1', createdAt: '2026-09-28T00:00:00.000Z', spaceId: null, title: null, approvalPolicy: 'manual', executionProfile: 'read_only' }) +} + +describe('attachment facts and ownership', () => { + it('preserves queued attachments until the queue relinquishes ownership', async () => { + const { database, repository, record, service, events } = await setup() + createConversation(database) + database.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) + VALUES ('draft-1', 'conversation-1', 'branch-1', 'request-1', 'fingerprint', '{}', 'waiting', '2026-09-28T00:00:00.000Z')`).run() + expect(await service.release([record.id])).toEqual([]) + expect(repository.findById(record.id)).not.toBeNull() + expect([...await readFile(record.storedPath)]).toEqual([1, 2, 3]) + expect(events.map(event => event.kind)).toEqual(['file-published', 'registered']) + database.prepare('UPDATE chat_queue SET state = \'cancelled\'').run() + expect(await service.release([record.id])).toEqual([record.id]) + expect(repository.findById(record.id)).toBeNull() + await expect(readFile(record.storedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('leases both draft and staged bytes until one immutable settlement completes', async () => { + const { record, service, events } = await setup() + const prepared = await service.prepareMessageAttachments({ attachmentIds: [record.id], conversationId: 'conversation-1', draftId: 'draft-1', messageId: 'message-1' }) + expect(Object.isFrozen(prepared.bindings[0])).toBe(true) + await expect(service.prepareMessageAttachments({ attachmentIds: [record.id], conversationId: 'conversation-1', draftId: 'draft-1', messageId: 'message-1' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(await service.release([record.id])).toEqual([]) + expect((await service.reconcileStorage()).removedOrphanFiles).toBe(0) + expect([...await readFile(prepared.bindings[0]!.storedPath)]).toEqual([1, 2, 3]) + await prepared.rollback() + await prepared.rollback() + await expect(prepared.commit()).rejects.toThrow('ATTACHMENT_PREPARATION_SETTLED') + expect(events.filter(event => event.phase === 'rollback' && event.kind === 'cleanup-completed')).toHaveLength(1) + expect(await service.release([record.id])).toEqual([record.id]) + }) + + it('does not remove database-owned message bytes even when a caller requests rollback', async () => { + const { database, record, service, repository } = await setup() + const prepared = await service.prepareMessageAttachments({ attachmentIds: [record.id], conversationId: 'conversation-1', draftId: 'draft-1', messageId: 'message-1' }) + createConversation(database) + database.prepare('INSERT INTO messages (id, conversation_id, branch_id, role, content_json, created_at) VALUES (\'message-1\', \'conversation-1\', \'branch-1\', \'user\', \'{}\', \'2026-09-28T00:00:00.000Z\')').run() + database.prepare('UPDATE attachments SET draft_id = NULL, message_id = \'message-1\', stored_path = ? WHERE id = ?').run(prepared.bindings[0]!.storedPath, record.id) + await prepared.rollback() + expect(repository.findById(record.id)?.messageId).toBe('message-1') + expect([...await readFile(prepared.bindings[0]!.storedPath)]).toEqual([1, 2, 3]) + expect((await service.reconcileStorage()).removedOrphanFiles).toBe(1) + }) + + it('rejects cleanup before an ownership commit and preserves the draft for recovery', async () => { + const { record, service, events } = await setup() + const prepared = await service.prepareMessageAttachments({ attachmentIds: [record.id], conversationId: 'conversation-1', draftId: 'draft-1', messageId: 'message-1' }) + await expect(prepared.commit()).rejects.toThrow('ATTACHMENT_OWNERSHIP_NOT_COMMITTED') + expect([...await readFile(record.storedPath)]).toEqual([1, 2, 3]) + expect(events.at(-1)).toMatchObject({ kind: 'cleanup-failed', phase: 'commit' }) + expect((await service.reconcileStorage()).removedOrphanFiles).toBe(1) + expect(events[0]!.operationId).toBe(events[1]!.operationId) + }) +}) + +describe('prepared upload ownership', () => { + it('releases only the newly prepared identity and keeps a completed queue transfer', async () => { + const { database, record, service, repository } = await setup() + const bytes = Uint8Array.of(65, 66) + const prepared = await service.prepareUploads('draft-1', [{ name: 'temporary.txt', mimeType: 'text/plain', bytes }]) + bytes.fill(0) + const temporary = prepared.records[0]! + expect(Object.isFrozen(temporary)).toBe(true) + expect(await service.release([temporary.id])).toEqual([]) + expect([...await readFile(temporary.storedPath)]).toEqual([65, 66]) + createConversation(database) + database.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) + VALUES ('queue-1', 'conversation-1', 'branch-1', 'request-1', 'fingerprint', '{}', 'cancelled', '2026-09-28T00:00:00.000Z')`).run() + database.prepare('UPDATE attachments SET draft_id = ? WHERE id = ?').run('queue-1', temporary.id) + await prepared.rollback() + expect(repository.findById(temporary.id)?.draftId).toBe('queue-1') + expect([...await readFile(temporary.storedPath)]).toEqual([65, 66]) + expect(repository.findById(record.id)).not.toBeNull() + expect(await service.release([temporary.id])).toEqual([temporary.id]) + }) +}) diff --git a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts index 403497d4..d4f4331a 100644 --- a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts +++ b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts @@ -2,6 +2,8 @@ import type { DatabaseSync } from 'node:sqlite' import type { BuddyUserContentV1 } from '../../../../shared/conversation/buddyUserContent' import type { BuddyComposerDraftScope } from '../../../../shared/conversation/composerDraft' import type { InputModel } from '../../providers/modelCapabilities' +import type { ComposerResourceChange } from '../composerResourceEvents' +import type { PreparedComposerInput } from '../ComposerResourceService' import { Buffer } from 'node:buffer' import { mkdir, mkdtemp, readdir, readFile, rm, symlink, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -38,6 +40,7 @@ import { AttachmentService, normalizeAttachmentMetadata } from '../AttachmentSer import { AttachmentToolWorkspace } from '../AttachmentToolWorkspace' import { ComposerResourceService } from '../ComposerResourceService' +const preparedInputs: PreparedComposerInput[] = [] const databases: DatabaseSync[] = [] const directories: string[] = [] @@ -60,6 +63,8 @@ describe('attachment validation errors', () => { }) }) afterEach(async () => { + for (const input of preparedInputs.splice(0)) + await input.rollback() databases.splice(0).forEach(database => database.close()) await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) @@ -106,6 +111,12 @@ async function setup() { } } +async function resolveInputs(service: ComposerResourceService, ...args: Parameters) { + const prepared = await service.resolveInput(...args) + preparedInputs.push(prepared) + return prepared.inputs +} + function imageBytes() { return Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAFElEQVR4AQEJAPb/AP8AAP8AAP//D/kD/aYucFEAAAAASUVORK5CYII=', 'base64') } @@ -124,7 +135,7 @@ describe('attachment submission validation', () => { await writeFile(local, bytes) await truncate(local, 10 * 1024 * 1024) await fixture.service.selectSource({ draftId: 'budget', resourceId: 'a-local', source: { localPath: local } }) - const result = await fixture.service.resolveInput('budget', { ...createBuddyUserContent(), panelResourceIds: ['a-local', 'b-copy', 'c-copy', 'd-copy'] }, { branchId: null, conversationId: null, spaceId: null }, { api: 'openai-completions', input: ['text'], fileInputMimeTypes: ['audio/wav'] }) + const result = await resolveInputs(fixture.service, 'budget', { ...createBuddyUserContent(), panelResourceIds: ['a-local', 'b-copy', 'c-copy', 'd-copy'] }, { branchId: null, conversationId: null, spaceId: null }, { api: 'openai-completions', input: ['text'], fileInputMimeTypes: ['audio/wav'] }) expect(result.map(resource => resource.resourceId)).toEqual(['a-local', 'b-copy', 'c-copy', 'd-copy']) expect(result[0]).toMatchObject({ localReference: { path: local } }) expect(result[0]!.attachmentId).toBeUndefined() @@ -141,7 +152,7 @@ describe('attachment submission validation', () => { const started = await fixture.turns.start({ draftId: first.draftId, expectedRevision: first.revision, requestId: 'first' }) const second = await fixture.draft('second', 'second.wav', bytes, { kind: 'conversation_branch', conversationId: started.conversationId, branchId: started.branchId }) const repository = createChatQueueRepository(fixture.database) - const queue = new ChatQueueService({ runInputs: createRunInputRepository(fixture.database), queue: repository, turns: fixture.turns, runs: fixture.runs, requests: createTurnRequestRepository(fixture.database), launcher: fixture.launcher, runner: { followUp: () => false, steer: (_runId, prepare) => { + const queue = new ChatQueueService({ eventLog: { state: 'open' }, runInputs: createRunInputRepository(fixture.database), queue: repository, turns: fixture.turns, runs: fixture.runs, requests: createTurnRequestRepository(fixture.database), launcher: fixture.launcher, runner: { hasActiveExecution: () => active, hasDegradedCleanup: () => false, isStopping: false, followUp: () => false, steer: (_runId, prepare) => { prepare() return true } } }) @@ -461,7 +472,7 @@ describe('composer resource import', () => { expect(filtered.files).toHaveLength(1) expect(filtered.files[0]).toMatchObject({ label: '[Image #2]', source: { messageId: 'images', resourceId: 'first-image' }, history: { messageNumber: 2 } }) const selected = await fixture.service.selectSource({ draftId: 'draft', resourceId: 'selected-image', source: filtered.files[0]!.source }) - expect(await fixture.service.resolveInput('draft', { ...createBuddyUserContent(), panelResourceIds: [selected.resourceId] }, scope)).toEqual([{ resourceId: selected.resourceId, attachmentId: 'first-image' }]) + expect(await resolveInputs(fixture.service, 'draft', { ...createBuddyUserContent(), panelResourceIds: [selected.resourceId] }, scope)).toEqual([{ resourceId: selected.resourceId, attachmentId: 'first-image' }]) }) it('lists and references directories, AVIF and oversized videos without importing their contents', async () => { @@ -510,10 +521,10 @@ describe('composer resource import', () => { const message = fixture.conversations.listBranchMessages(turn.conversationId, turn.branchId).find(message => message.role === 'user')! await rm(image) const historical = await fixture.service.selectSource({ draftId: 'edit', resourceId: 'historical', source: { conversationId: turn.conversationId, branchId: turn.branchId, messageId: message.id, resourceId: resource!.resourceId } }) - const resolved = await fixture.service.resolveInput('edit', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] }, { conversationId: turn.conversationId, branchId: turn.branchId, spaceId: null }, { api: 'google-generative-ai', input: ['text', 'image'], fileInputMimeTypes: [] }) + const resolved = await resolveInputs(fixture.service, 'edit', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] }, { conversationId: turn.conversationId, branchId: turn.branchId, spaceId: null }, { api: 'google-generative-ai', input: ['text', 'image'], fileInputMimeTypes: [] }) expect(resolved).toMatchObject([{ resourceId: 'historical', attachmentId: snapshot.id, localReference: { path: image } }]) expect(await readFile(snapshot.storedPath)).toEqual(imageBytes()) - await expect(fixture.service.resolveInput('edit', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] })).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) + await expect(resolveInputs(fixture.service, 'edit', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] })).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) }) it('keeps unsupported model inputs as local references and accepts mixed batches atomically', async () => { @@ -524,7 +535,7 @@ describe('composer resource import', () => { const accepted = await fixture.service.accept(input) expect(accepted.map(resource => resource.resourceId)).toEqual(['one', 'two', 'copy']) await fixture.service.complete({ draftId: 'batch', resourceId: 'copy', bytes: Buffer.from('hi') }) - const resolved = await fixture.service.resolveInput('batch', { ...createBuddyUserContent(), panelResourceIds: ['one', 'two', 'copy'] }, { branchId: null, conversationId: null, spaceId: null }, { api: 'openai-completions', input: ['text'], fileInputMimeTypes: [] }) + const resolved = await resolveInputs(fixture.service, 'batch', { ...createBuddyUserContent(), panelResourceIds: ['one', 'two', 'copy'] }, { branchId: null, conversationId: null, spaceId: null }, { api: 'openai-completions', input: ['text'], fileInputMimeTypes: [] }) expect(resolved).toMatchObject([{ localReference: { path } }, { localReference: { path } }, { attachmentId: expect.any(String) }]) expect(resolved.slice(0, 2).every(resource => !resource.attachmentId)).toBe(true) await expect(fixture.service.accept({ draftId: 'invalid', resources: [input.resources[0]!, { ...input.resources[1]!, sourcePath: join(fixture.root, 'missing') }] })).rejects.toThrow() @@ -659,11 +670,7 @@ describe('composer resource import', () => { resourceId: 'history-resource', source: history.source, }) - await expect(fixture.service.resolveInput( - 'draft-1', - { ...createBuddyUserContent(), panelResourceIds: [selectedHistory.resourceId] }, - { branchId: 'branch-1', conversationId: 'conversation-1', spaceId: null }, - )).resolves.toEqual([{ + await expect(resolveInputs(fixture.service, 'draft-1', { ...createBuddyUserContent(), panelResourceIds: [selectedHistory.resourceId] }, { branchId: 'branch-1', conversationId: 'conversation-1', spaceId: null })).resolves.toEqual([{ attachmentId: 'attachment-history', resourceId: selectedHistory.resourceId, }]) @@ -755,7 +762,7 @@ describe('composer resource import', () => { expect(catalog.files.filter(item => item.category === 'history').map(item => item.label)).toEqual(['original.txt', 'history.txt']) const historical = await fixture.service.selectSource({ draftId: 'draft-1', resourceId: 'historical-local', source: catalog.files.find(item => item.label === 'original.txt')!.source }) await rm(historicalPath) - expect(await fixture.service.resolveInput('draft-1', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] }, { branchId: 'branch-1', conversationId: 'conversation-1', spaceId })).toEqual([{ resourceId: 'historical-local', localReference }]) + expect(await resolveInputs(fixture.service, 'draft-1', { ...createBuddyUserContent(), panelResourceIds: [historical.resourceId] }, { branchId: 'branch-1', conversationId: 'conversation-1', spaceId })).toEqual([{ resourceId: 'historical-local', localReference }]) const artifactOption = catalog.files.find(item => item.category === 'artifact') expect(artifactOption).toMatchObject({ label: 'artifact.txt', @@ -766,11 +773,7 @@ describe('composer resource import', () => { resourceId: 'artifact-resource', source: artifactOption!.source, }) - const input = await fixture.service.resolveInput( - 'draft-1', - { ...createBuddyUserContent(), panelResourceIds: [selected.resourceId] }, - { branchId: 'branch-1', conversationId: 'conversation-1', spaceId }, - ) + const input = await resolveInputs(fixture.service, 'draft-1', { ...createBuddyUserContent(), panelResourceIds: [selected.resourceId] }, { branchId: 'branch-1', conversationId: 'conversation-1', spaceId }) expect(input).toEqual([{ resourceId: selected.resourceId, localReference: { kind: 'file', mimeType: 'text/plain', name: 'artifact.txt', path: artifactPath, sizeBytes: 8 } }]) expect(fixture.attachmentRepository.listDraftsBefore('9999')).toEqual([]) const foreignRoot = join(fixture.root, 'foreign-workspace') @@ -854,13 +857,13 @@ describe('composer resource import', () => { const history = catalog.files.find(item => item.category === 'history')! const firstHistory = await fixture.service.selectSource({ draftId: 'draft-1', resourceId: 'history-1', source: history.source }) expect(await fixture.service.selectSource({ draftId: 'draft-1', resourceId: 'history-2', source: history.source })).toEqual(firstHistory) - const historyInput = await fixture.service.resolveInput('draft-1', { ...createBuddyUserContent(), panelResourceIds: [firstHistory.resourceId] }, scope) + const historyInput = await resolveInputs(fixture.service, 'draft-1', { ...createBuddyUserContent(), panelResourceIds: [firstHistory.resourceId] }, scope) expect(historyInput).toEqual([{ attachmentId: 'attachment-history', resourceId: firstHistory.resourceId }]) const artifactOption = catalog.files.find(item => item.category === 'artifact')! const artifactResource = await fixture.service.selectSource({ draftId: 'draft-1', resourceId: 'artifact-1', source: artifactOption.source }) await writeFile(artifactPath, 'at send') - const artifactInput = await fixture.service.resolveInput('draft-1', { ...createBuddyUserContent(), panelResourceIds: [artifactResource.resourceId] }, scope) + const artifactInput = await resolveInputs(fixture.service, 'draft-1', { ...createBuddyUserContent(), panelResourceIds: [artifactResource.resourceId] }, scope) expect(artifactInput).toEqual([{ resourceId: artifactResource.resourceId, localReference: { kind: 'file', mimeType: 'text/plain', name: 'artifact.txt', path: artifactPath, sizeBytes: 7 } }]) await writeFile(artifactPath, 'later') expect(artifactInput[0]!.localReference!.sizeBytes).toBe(7) @@ -886,10 +889,13 @@ describe('composer resource import', () => { primaryDirectory: { id: 'binding-1', root: directory, canonicalRoot: directory, accessGrantedAt: 'now', resourcesTrustedAt: 'now' }, }) const input = { draftId: 'draft-1', resourceId: 'resource-1', source: { spaceId: 'space-1', bindingId: 'binding-1', relativePath: 'note.txt' } } + const facts: ComposerResourceChange[] = [] + service.onDidChange(event => facts.push(event)) const selected = await service.selectSpaceFile(input) expect(selected).toMatchObject({ resourceId: 'resource-1', state: 'ready', sourcePath: join(directory, 'note.txt'), source: { origin: { ...input.source, bindingRevision: 1 } } }) expect(await service.selectSpaceFile(input)).toEqual(selected) expect(await service.selectSpaceFile({ ...input, resourceId: 'resource-duplicate' })).toEqual(selected) + expect(facts).toMatchObject([{ reason: 'selection', resources: [{ kind: 'created', resourceId: selected.resourceId }] }]) expect(database.prepare('SELECT count(*) AS count FROM attachments').get()).toEqual({ count: 0 }) const content = { ...createBuddyUserContent(), panelResourceIds: ['resource-1'] } drafts.open({ @@ -903,13 +909,13 @@ describe('composer resource import', () => { await service.cleanupDrafts(new Date('2099-01-01').getTime()) expect(service.list('draft-1')).toContainEqual(selected) await writeFile(join(directory, 'note.txt'), 'at send') - const frozen = await service.resolveInput('draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' }) + const frozen = await resolveInputs(service, 'draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' }) expect(frozen[0]).toEqual({ resourceId: 'resource-1', localReference: { kind: 'file', mimeType: 'text/plain', name: 'note.txt', path: join(directory, 'note.txt'), sizeBytes: 7 } }) expect(attachmentRepository.listDraftsBefore('9999')).toEqual([]) await writeFile(join(directory, 'note.txt'), 'later') expect(frozen[0]!.localReference!.sizeBytes).toBe(7) - expect((await service.resolveInput('draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' }))[0]!.localReference!.sizeBytes).toBe(5) - await expect(service.resolveInput('draft-1', content)).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) + expect((await resolveInputs(service, 'draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' }))[0]!.localReference!.sizeBytes).toBe(5) + await expect(resolveInputs(service, 'draft-1', content)).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) const legacy = { drafts: [{ draftId: 'draft-1', targetKey: 'space:space-1', content: '$writer @note.txt', composerContent: { type: 'doc', attrs: { panelResourceIds: [] }, content: [{ type: 'paragraph', content: [ { type: 'chatPromptToken', attrs: { kind: 'skill', value: 'writer' } }, { type: 'text', text: ' ' }, @@ -924,14 +930,33 @@ describe('composer resource import', () => { { type: 'chatResourceReference', attrs: { resourceId: 'resource-1' } }, ] }] } }] }) expect(legacy.drafts[0]!.composerContent.content[0]!.content[0]!.type).toBe('chatPromptToken') + expect(await normalizeComposerWorkspace(legacy, { resources: service, conversations: createConversationRepository(database) })).toEqual(normalized) + expect(facts).toHaveLength(1) + const partial = { drafts: [{ draftId: 'partial', targetKey: 'space:space-1', composerContent: { type: 'doc', content: ['note.txt', 'missing.txt'].map(value => ({ type: 'chatPromptToken', attrs: { kind: 'file', value } })) } }] } + const conversions: Promise[] = [] + const resources = { selectSpaceFilePath: (...args: Parameters) => { + const conversion = service.selectSpaceFilePath(...args) + conversions.push(conversion) + return conversion + } } + await expect(normalizeComposerWorkspace(partial, { resources, conversations: createConversationRepository(database) })).rejects.toBeInstanceOf(Error) + await Promise.allSettled(conversions) + const retained = service.list('partial')[0]! + expect(service.list('partial')).toHaveLength(1) + expect(facts.at(-1)).toMatchObject({ reason: 'compatibility', resources: [{ kind: 'created', resourceId: retained.resourceId }] }) + await writeFile(join(directory, 'missing.txt'), 'recovered') + await normalizeComposerWorkspace(partial, { resources: service, conversations: createConversationRepository(database) }) + expect(service.list('partial')).toHaveLength(2) + expect(service.list('partial')).toContainEqual(retained) + expect(facts.flatMap(fact => fact.resources).filter(resource => resource.resourceId === retained.resourceId)).toHaveLength(1) await writeFile(join(directory, '.env'), 'synthetic=value') await expect(service.selectSpaceFile({ ...input, resourceId: 'sensitive', source: { ...input.source, relativePath: '.env' } })).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) await writeFile(join(directory, 'note.txt'), Uint8Array.of(255)) - await expect(service.resolveInput('draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' })).resolves.toMatchObject([{ localReference: { sizeBytes: 1 } }]) + await expect(resolveInputs(service, 'draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' })).resolves.toMatchObject([{ localReference: { sizeBytes: 1 } }]) await rm(join(directory, 'note.txt')) await writeFile(join(root, 'outside.txt'), 'outside') await symlink(join(root, 'outside.txt'), join(directory, 'note.txt')) - await expect(service.resolveInput('draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' })).rejects.toBeInstanceOf(Error) + await expect(resolveInputs(service, 'draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' })).rejects.toBeInstanceOf(Error) }) it('sends a panel-only resource through the real turn transaction and replays the immutable input', async () => { @@ -1253,6 +1278,8 @@ describe('composer resource import', () => { it('accepts metadata before bytes, binds a distinct immutable file identity, and replays completion', async () => { const { service, paths, attachmentRepository } = await setup() + const facts: ComposerResourceChange[] = [] + service.onDidChange(event => facts.push(event)) const bytes = imageBytes() const input = { draftId: 'draft-1', resources: [{ resourceId: 'resource-1', mimeType: 'image/png', name: 'two-pixels.png', sizeBytes: bytes.length }] } expect(await service.accept(input)).toEqual([{ ...input.resources[0], nameSource: 'file', draftId: 'draft-1', kind: 'image', state: 'importing' }]) @@ -1268,6 +1295,7 @@ describe('composer resource import', () => { expect(await service.accept(input)).toEqual([ready]) await expect(service.complete({ draftId: 'draft-1', resourceId: 'resource-1', bytes: Uint8Array.of(1) })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) expect(service.list('draft-1')).toEqual([ready]) + expect(facts.map(fact => fact.resources.map(resource => [resource.kind, resource.state]))).toEqual([[['created', 'importing']], [['changed', 'ready']]]) }) it('rejects a whole invalid or conflicting metadata batch without partially accepting it', async () => { @@ -1398,10 +1426,186 @@ describe('composer resource import', () => { { type: 'resource_ref' as const, resourceId: 'blue' }, { type: 'resource_ref' as const, resourceId: 'red' }, ] }] } - const bindings = await service.resolveInput('draft-1', content) + const bindings = await resolveInputs(service, 'draft-1', content) const result = await attachments.materializePrompt(bindings.flatMap(binding => binding.attachmentId ? [binding.attachmentId] : []), '', null, 'draft-1', { content, resourceIds: bindings.map(binding => binding.resourceId) }) expect(result.prompt).toBe('[FILE#1]\n\n[FILE#2] vs [FILE#3][FILE#2]\n\n[FILE#1] "note.txt" (TEXT)\nabc\n\n[FILE#2] "red.png" (IMAGE)\n\n[FILE#3] "blue.png" (IMAGE)') expect(result.images).toHaveLength(2) expect(result.records).toHaveLength(3) }) }) + +describe('composer input preparation ownership', () => { + const model = { api: 'openai-completions', input: ['text', 'image'] as Array<'text' | 'image'>, fileInputMimeTypes: [] } + + async function local(fixture: Awaited>, draftId: string, resourceId: string) { + const path = join(fixture.root, `${resourceId}.png`) + await writeFile(path, imageBytes()) + await fixture.service.selectSource({ draftId, resourceId, source: { localPath: path } }) + return path + } + + it('rolls back only newly converted bytes when a later resource cannot be read', async () => { + const f = await setup() + await f.service.accept({ draftId: 'partial', resources: [{ resourceId: 'existing', name: 'existing.txt', mimeType: 'text/plain', sizeBytes: 1 }] }) + await f.service.complete({ draftId: 'partial', resourceId: 'existing', bytes: Uint8Array.of(65) }) + const existing = f.attachmentRepository.listAll()[0]! + const source = await local(f, 'partial', 'first') + const missing = await local(f, 'partial', 'second') + await rm(missing) + for (let retry = 0; retry < 2; retry++) { + await expect(f.service.resolveInput('partial', { ...createBuddyUserContent(), panelResourceIds: ['existing', 'first', 'second'] }, undefined, model)).rejects.toBeInstanceOf(Error) + expect(f.attachmentRepository.listAll()).toEqual([existing]) + expect(await readdir(f.paths.draftAttachments('partial'))).toHaveLength(1) + } + expect(await readFile(source)).toEqual(imageBytes()) + await f.attachments.dispose() + }) + + it('reclaims a late conversion when discard completes before file registration', async () => { + const f = await setup() + await local(f, 'discarded', 'local') + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + const prepareUploads = f.attachments.prepareUploads.bind(f.attachments) + f.attachments.prepareUploads = async (...args) => { + entered.resolve() + await resume.promise + return prepareUploads(...args) + } + const resolving = f.service.resolveInput('discarded', { ...createBuddyUserContent(), panelResourceIds: ['local'] }, undefined, model) + const rejected = expect(resolving).rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + await entered.promise + await f.service.discard('discarded') + resume.resolve() + await rejected + expect(f.service.list('discarded')).toEqual([]) + expect(f.attachmentRepository.listAll()).toEqual([]) + expect(await readdir(f.paths.draftAttachments('discarded'))).toEqual([]) + await f.attachments.dispose() + }) + + it('leases prepared bytes until rollback and rejects a resource discarded before SQL handoff', async () => { + const f = await setup() + await local(f, 'held', 'local') + const prepared = await f.service.resolveInput('held', { ...createBuddyUserContent(), panelResourceIds: ['local'] }, undefined, model) + const owned = f.attachmentRepository.findById(prepared.inputs[0]!.attachmentId!)! + expect(Object.isFrozen(prepared.inputs[0])).toBe(true) + await f.service.discard('held') + expect(await readFile(owned.storedPath)).toEqual(imageBytes()) + expect(() => prepared.validate()).toThrow(expect.objectContaining({ code: 'ATTACHMENT_NOT_FOUND' })) + await prepared.rollback() + await prepared.rollback() + expect(f.attachmentRepository.listAll()).toEqual([]) + await expect(readFile(owned.storedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await f.attachments.dispose() + }) + + it('reclaims an upload when the resource binding SQL fails and permits a clean retry', async () => { + const f = await setup() + await f.service.accept({ draftId: 'import', resources: [{ resourceId: 'item', name: 'note.txt', mimeType: 'text/plain', sizeBytes: 1 }] }) + f.database.exec(`CREATE TRIGGER reject_resource_ready BEFORE UPDATE ON composer_resources WHEN NEW.state = 'ready' BEGIN SELECT RAISE(ABORT, 'fixture binding failure'); END`) + expect(await f.service.complete({ draftId: 'import', resourceId: 'item', bytes: Uint8Array.of(65) })).toMatchObject({ state: 'failed' }) + expect(f.attachmentRepository.listAll()).toEqual([]) + expect(await readdir(f.paths.draftAttachments('import'))).toEqual([]) + f.database.exec('DROP TRIGGER reject_resource_ready') + f.service.retry({ draftId: 'import', resourceId: 'item' }) + expect(await f.service.complete({ draftId: 'import', resourceId: 'item', bytes: Uint8Array.of(65) })).toMatchObject({ state: 'ready' }) + expect(f.attachmentRepository.listAll()).toHaveLength(1) + await f.attachments.dispose() + }) + + it('preserves an upload whose resource binding committed before an error was reported', async () => { + const f = await setup() + await f.service.accept({ draftId: 'import', resources: [{ resourceId: 'item', name: 'note.txt', mimeType: 'text/plain', sizeBytes: 1 }] }) + const finish = f.repository.finish.bind(f.repository) + f.repository.finish = (input) => { + finish(input) + throw new Error('fixture post-commit read failure') + } + const result = await f.service.complete({ draftId: 'import', resourceId: 'item', bytes: Uint8Array.of(65) }) + expect(result).toMatchObject({ state: 'ready' }) + const record = f.attachmentRepository.findById(f.repository.findById('item')!.attachmentId!)! + expect(await readFile(record.storedPath)).toEqual(Buffer.from('A')) + await f.attachments.dispose() + }) + + it('drains an accepted import through binding and lease release after dispose begins', async () => { + const f = await setup() + await f.service.accept({ draftId: 'draining', resources: [{ resourceId: 'item', name: 'note.txt', mimeType: 'text/plain', sizeBytes: 1 }] }) + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + const prepareUploads = f.attachments.prepareUploads.bind(f.attachments) + f.attachments.prepareUploads = async (...args) => { + const upload = await prepareUploads(...args) + entered.resolve() + await resume.promise + return upload + } + const bytes = Uint8Array.of(65) + const completing = f.service.complete({ draftId: 'draining', resourceId: 'item', bytes }) + bytes.fill(0) + await entered.promise + let disposed = false + const stopping = f.service.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + resume.resolve() + expect(await completing).toMatchObject({ state: 'ready' }) + await stopping + const record = f.attachmentRepository.listAll()[0]! + expect(await readFile(record.storedPath)).toEqual(Buffer.from('A')) + await f.attachments.dispose() + }) + + it.each(['start', 'enqueue', 'edit'] as const)('reclaims materialized bytes after failed %s SQL and transfers ownership on retry', async (mode) => { + const f = await checkedTurns({ api: 'openai-completions', input: ['text', 'image'] }) + let scope: BuddyComposerDraftScope = { kind: 'global' } + let editing: { conversationId: string, userMessageId: string } | undefined + if (mode !== 'start') { + const first = f.drafts.open({ draftId: 'first', initialContent: createBuddyUserContent('hello'), initialExecutionConfig: { approvalPolicy: 'manual', executionProfile: 'read_only' }, initialModelSelection: null, scope, now: new Date().toISOString() }) + const turn = await f.turns.start({ draftId: first.draftId, expectedRevision: first.revision, requestId: 'first' }) + if (mode === 'enqueue') { + scope = { kind: 'conversation_branch', conversationId: turn.conversationId, branchId: turn.branchId } + } + else { + editing = { conversationId: turn.conversationId, userMessageId: f.runs.findById(turn.runId)!.triggeringMessageId } + scope = { kind: 'message_edit', conversationId: turn.conversationId, branchId: turn.branchId, userMessageId: editing.userMessageId } + } + } + const content = { ...createBuddyUserContent('Inspect'), panelResourceIds: ['native'] } + const opened = f.drafts.open({ draftId: 'send', initialContent: content, initialExecutionConfig: { approvalPolicy: 'manual', executionProfile: 'read_only' }, initialModelSelection: null, scope, now: new Date().toISOString() }) + const draft = f.drafts.save({ ...opened, content, expectedRevision: opened.revision, now: new Date().toISOString() }) + const source = await local(f, draft.draftId, 'native') + const queue = mode === 'enqueue' ? new ChatQueueService({ eventLog: { state: 'open' }, runInputs: createRunInputRepository(f.database), queue: createChatQueueRepository(f.database), turns: f.turns, runs: f.runs, requests: createTurnRequestRepository(f.database), launcher: f.launcher, runner: { hasActiveExecution: () => false, hasDegradedCleanup: () => false, isStopping: false, followUp: () => false, steer: () => false } }) : null + const submit = () => { + const input = { draftId: draft.draftId, expectedRevision: draft.revision, requestId: 'materialized' } + return queue ? queue.enqueue(input) : editing ? f.turns.editUserMessage({ ...input, ...editing }) : f.turns.start(input) + } + try { + f.database.exec(`CREATE TRIGGER reject_submission BEFORE INSERT ON ${mode === 'enqueue' ? 'chat_queue' : 'runs'} BEGIN SELECT RAISE(ABORT, 'fixture submission failure'); END`) + await expect(submit()).rejects.toThrow('fixture submission failure') + expect(f.attachmentRepository.listAll()).toEqual([]) + expect(f.drafts.findById(draft.draftId)).toEqual(draft) + expect(await readdir(f.paths.draftAttachments(draft.draftId))).toEqual([]) + f.database.exec('DROP TRIGGER reject_submission') + const outcomes = await Promise.all([submit(), submit()]) + expect(outcomes[1]).toEqual(outcomes[0]) + const result = outcomes[0]! + const owned = f.attachmentRepository.listAll()[0]! + expect(f.attachmentRepository.listAll()).toHaveLength(1) + expect(owned.draftId).toBe('id' in result ? result.id : null) + expect(await readFile(owned.storedPath)).toEqual(imageBytes()) + expect(await readFile(source)).toEqual(imageBytes()) + expect(await readdir(f.paths.draftAttachments(draft.draftId))).toEqual([]) + } + finally { + queue?.dispose() + await queue?.drain() + await f.turns.dispose() + await f.service.dispose() + await f.attachments.dispose() + } + }) +}) diff --git a/apps/buddy/service/src/attachments/attachmentEvents.ts b/apps/buddy/service/src/attachments/attachmentEvents.ts new file mode 100644 index 00000000..bce0dcc7 --- /dev/null +++ b/apps/buddy/service/src/attachments/attachmentEvents.ts @@ -0,0 +1,8 @@ +export interface AttachmentChange { + readonly revision: number + readonly operationId: string + readonly kind: 'file-published' | 'registered' | 'released' | 'prepared' | 'cleanup-completed' | 'cleanup-failed' | 'reconciled' + readonly phase: 'import' | 'message' | 'commit' | 'rollback' | 'release' | 'recovery' + readonly attachmentIds: readonly string[] + readonly count: number +} diff --git a/apps/buddy/service/src/attachments/composerResourceEvents.ts b/apps/buddy/service/src/attachments/composerResourceEvents.ts new file mode 100644 index 00000000..c2ab4c24 --- /dev/null +++ b/apps/buddy/service/src/attachments/composerResourceEvents.ts @@ -0,0 +1,14 @@ +import type { ComposerResourceRecord } from '../storage/composerResourceRepository' + +export type ComposerResourceChangeReason = 'import' | 'selection' | 'recovery' | 'cleanup' | 'discard' | 'compatibility' +export interface ComposerResourceChange { + readonly revision: number + readonly operationId: string + readonly reason: ComposerResourceChangeReason + readonly resources: readonly { + readonly resourceId: string + readonly draftId: string + readonly kind: 'created' | 'changed' | 'removed' + readonly state: ComposerResourceRecord['state'] | null + }[] +} diff --git a/apps/buddy/service/src/automations/AgentTaskAutomationAction.ts b/apps/buddy/service/src/automations/AgentTaskAutomationAction.ts index 5b1bf966..baf8a772 100644 --- a/apps/buddy/service/src/automations/AgentTaskAutomationAction.ts +++ b/apps/buddy/service/src/automations/AgentTaskAutomationAction.ts @@ -37,7 +37,7 @@ export interface AgentTaskAutomationActionOptions { cancelRun?: (runId: string, errorCode: string) => Promise clock?: AutomationClock createId?: () => string - launchTurn: (runId: string) => Promise + launchTurn: (runId: string, signal?: AbortSignal) => Promise resolveModel: (target: AutomationModelTarget) => Promise resolveSpace: ( spaceId: string, @@ -73,9 +73,11 @@ export class AgentTaskAutomationAction implements AutomationActionExecutor { this.#turns = options.turns } - async execute(occurrence: AutomationOccurrenceRecord & { leaseOwner: string }): Promise { + async execute(occurrence: AutomationOccurrenceRecord & { leaseOwner: string }, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const snapshot = occurrence.executionSnapshot const model = await this.#resolveModel(snapshot.model) + signal?.throwIfAborted() if (!model) { const errorCode = snapshot.model.mode === 'pinned' ? 'AUTOMATION_PINNED_MODEL_UNAVAILABLE' @@ -103,6 +105,7 @@ export class AgentTaskAutomationAction implements AutomationActionExecutor { const spaceResolution = snapshot.spaceId && snapshot.spaceContext ? await this.#resolveSpace(snapshot.spaceId, snapshot.spaceContext) : null + signal?.throwIfAborted() if (snapshot.spaceId && !spaceResolution) { this.#skipAndBlock( occurrence.id, @@ -167,7 +170,7 @@ export class AgentTaskAutomationAction implements AutomationActionExecutor { return } const bound = binding - const turn = await this.#launchTurn(bound.run.id) + const turn = await this.#launchTurn(bound.run.id, signal) await this.#waitForTurn(turn) } diff --git a/apps/buddy/service/src/automations/AutomationChangeCoordinator.ts b/apps/buddy/service/src/automations/AutomationChangeCoordinator.ts index 45bdb8dc..430326f7 100644 --- a/apps/buddy/service/src/automations/AutomationChangeCoordinator.ts +++ b/apps/buddy/service/src/automations/AutomationChangeCoordinator.ts @@ -1,9 +1,21 @@ import type { Automation } from '../../../shared/automation' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { RunEventObservation } from '../events/RunEventPorts' +import type { RunLifecycleService } from '../runs/RunLifecycleService' +import type { RunRepository } from '../storage/runRepository' +import type { AutomationCommit } from './AutomationEvents' import type { AutomationService } from './AutomationService' +import type { AutomationTurnService } from './AutomationTurnService' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { affectsAutomationSchedule } from './AutomationEvents' export interface AutomationChangeCoordinatorOptions { notify: (automationId: string) => void - service: Pick + service: Pick + turns?: Pick + runChanges?: { eventLog: Pick, lifecycle: Pick, runs: Pick } + onError?: (error: unknown) => void wakeScheduler: () => Promise | void | undefined } @@ -13,34 +25,66 @@ export interface AutomationDependencyAvailability { } export class AutomationChangeCoordinator { - readonly #notify: AutomationChangeCoordinatorOptions['notify'] + readonly #subscriptions: EventSubscription[] = [] + readonly #pending = new Set>() + readonly #changes: Emitter> + readonly onDidChange: Emitter>['event'] + readonly #onError: (error: unknown) => void + #health: 'ready' | 'degraded' | 'stopped' = 'ready' + #stopping = false + #wakeBatch = 0 + #wakeNeeded = false + #wakeFailed = false + readonly #pendingRuns = new Set() + readonly #runChanges: AutomationChangeCoordinatorOptions['runChanges'] readonly #service: AutomationChangeCoordinatorOptions['service'] readonly #wakeScheduler: AutomationChangeCoordinatorOptions['wakeScheduler'] constructor(options: AutomationChangeCoordinatorOptions) { - this.#notify = options.notify + this.#onError = options.onError ?? (() => {}) + this.#changes = new Emitter(this.#onError) + this.onDidChange = this.#changes.event + this.#subscriptions.push(this.onDidChange(event => options.notify(event.automationId))) this.#service = options.service this.#wakeScheduler = options.wakeScheduler + this.#subscriptions.push(options.service.onDidCommit(event => this.#consume(event))) + if (options.turns) + this.#subscriptions.push(options.turns.onDidCommit(event => this.#consume(event))) + this.#runChanges = options.runChanges + const changes = this.#runChanges + if (changes) { + const changedRun = (runId: string) => { + this.#pendingRuns.add(runId) + this.reconcile() + } + this.#subscriptions.push(changes.eventLog.onDidCommit((event) => { + if (event.type.startsWith('run.') || event.type === 'approval.requested' || event.type === 'approval.resolved') + changedRun(event.runId) + }), changes.lifecycle.onDidReconcile(event => changedRun(event.runId))) + } } blockPinnedModel(providerId: string, modelId?: string): Automation[] { - return this.#publishAll(this.#service.blockPinnedModel(providerId, modelId)) + return this.#service.blockPinnedModel(providerId, modelId) } blockSpace(spaceId: string): Automation[] { - return this.#publishAll(this.#service.blockSpace(spaceId)) - } - - publish(automationId: string): void { - this.#notify(automationId) - void this.#wakeScheduler() + return this.#service.blockSpace(spaceId) } - publishSchedulerChange(automationId: string): void { - this.#notify(automationId) + reconcileDependencies(availability: AutomationDependencyAvailability): Automation[] { + this.#wakeBatch++ + try { + return this.#reconcileDependencies(availability) + } + finally { + this.#wakeBatch-- + if (!this.#wakeBatch && this.#wakeNeeded) + this.wakeScheduler() + } } - reconcileDependencies(availability: AutomationDependencyAvailability): Automation[] { + #reconcileDependencies(availability: AutomationDependencyAvailability): Automation[] { const active = this.#listActive() const blocked = new Map() const blockedSpaces = new Set() @@ -77,11 +121,96 @@ export class AutomationChangeCoordinator { } } - return this.#publishAll([...blocked.values()]) + return [...blocked.values()] + } + + get state() { return this.#health } + + reconcile(): void { + const changes = this.#runChanges + if (!changes) + return + for (const runId of this.#pendingRuns) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const run = changes.runs.findById(runId) + const occurrence = run ? this.#service.getOccurrenceByConversationId(run.conversationId) : null + this.#pendingRuns.delete(runId) + if (occurrence) + this.#notify(occurrence.automationId) + break + } + catch (error) { + if (attempt === 2) + this.#fail(error) + } + } + } + this.#updateHealth() + } + + #updateHealth(): void { + if (!this.#stopping) + this.#health = this.#wakeFailed || this.#pendingRuns.size ? 'degraded' : 'ready' } wakeScheduler(): void { - void this.#wakeScheduler() + if (this.#stopping) + return + this.reconcile() + this.#wakeNeeded = true + if (this.#wakeBatch) + return + this.#wakeNeeded = false + try { + const pending = Promise.resolve(this.#wakeScheduler()).then(() => { + this.#wakeFailed = false + this.#updateHealth() + }, (error) => { + this.#wakeFailed = true + this.#fail(error) + }).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + } + catch (error) { + this.#wakeFailed = true + this.#fail(error) + } + } + + async whenIdle(): Promise { + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + async dispose(): Promise { + this.#stopping = true + await this.whenIdle() + this.reconcile() + for (const subscription of this.#subscriptions.splice(0)) subscription.dispose() + this.#health = 'stopped' + this.#changes.dispose() + } + + #consume(event: AutomationCommit): void { + this.reconcile() + for (const automationId of new Set(event.facts.map(fact => fact.automationId))) + this.#notify(automationId) + if (affectsAutomationSchedule(event)) + this.wakeScheduler() + } + + #notify(automationId: string): void { + this.#changes.fire(copyEventSnapshot({ automationId })) + } + + #fail(error: unknown): void { + if (!this.#stopping) + this.#health = 'degraded' + try { + this.#onError(error) + } + catch {} } #listActive(): Automation[] { @@ -98,13 +227,4 @@ export class AutomationChangeCoordinator { } while (cursor) return active } - - #publishAll(automations: Automation[]): Automation[] { - if (automations.length === 0) - return automations - for (const automation of automations) - this.#notify(automation.id) - void this.#wakeScheduler() - return automations - } } diff --git a/apps/buddy/service/src/automations/AutomationDispatcher.ts b/apps/buddy/service/src/automations/AutomationDispatcher.ts index 7d43c494..eb3a4e26 100644 --- a/apps/buddy/service/src/automations/AutomationDispatcher.ts +++ b/apps/buddy/service/src/automations/AutomationDispatcher.ts @@ -1,32 +1,71 @@ import type { AutomationOccurrence } from '../../../shared/automation' import type { AutomationOccurrenceRecord } from '../storage/automationOccurrenceRecord' import type { AutomationService } from './AutomationService' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export interface AutomationActionExecutor { - execute: (occurrence: AutomationOccurrenceRecord & { leaseOwner: string }) => Promise + execute: (occurrence: AutomationOccurrenceRecord & { leaseOwner: string }, signal?: AbortSignal) => Promise } +export type AutomationDispatchChange = Readonly< + | { kind: 'started' | 'returned' | 'failed', automationId: string, occurrenceId: string } + | { kind: 'stopping' | 'drained', count: number } +> + export class AutomationDispatcher { readonly #service: Pick readonly #action: AutomationActionExecutor readonly #pending = new Map>() + readonly #stopping = new AbortController() + readonly #changes: Emitter + readonly onDidChange: Emitter['event'] - constructor(service: Pick, action: AutomationActionExecutor) { + constructor(service: Pick, action: AutomationActionExecutor, onObserverError: (error: unknown) => void = () => {}) { this.#service = service this.#action = action + this.#changes = new Emitter(onObserverError) + this.onDidChange = this.#changes.event } dispatch(candidate: AutomationOccurrence): Promise { - const pending = this.#pending.get(candidate.id) + if (this.#stopping.signal.aborted) + return Promise.reject(new DOMException('Automation dispatch is stopping', 'AbortError')) + const id = candidate.id + const pending = this.#pending.get(id) if (pending) return pending const operation = Promise.resolve().then(async () => { - const occurrence = this.#service.getOccurrence(candidate.id) + this.#stopping.signal.throwIfAborted() + const occurrence = this.#service.getOccurrence(id) if (!occurrence || occurrence.status !== 'queued' || occurrence.runId || !occurrence.leaseOwner) return - await this.#action.execute({ ...occurrence, leaseOwner: occurrence.leaseOwner }) - }).finally(() => this.#pending.delete(candidate.id)) - this.#pending.set(candidate.id, operation) + const identity = { automationId: occurrence.automationId, occurrenceId: occurrence.id } + this.#changes.fire(copyEventSnapshot({ kind: 'started', ...identity })) + try { + await this.#action.execute({ ...occurrence, leaseOwner: occurrence.leaseOwner }, this.#stopping.signal) + this.#changes.fire(copyEventSnapshot({ kind: 'returned', ...identity })) + } + catch (error) { + this.#changes.fire(copyEventSnapshot({ kind: 'failed', ...identity })) + throw error + } + }).finally(() => this.#pending.delete(id)) + this.#pending.set(id, operation) return operation } + + stop(): void { + if (this.#stopping.signal.aborted) + return + this.#stopping.abort(new DOMException('Automation dispatch is stopping', 'AbortError')) + this.#changes.fire(copyEventSnapshot({ kind: 'stopping', count: this.#pending.size })) + } + + async dispose(): Promise { + this.stop() + await Promise.allSettled([...this.#pending.values()]) + this.#changes.fire(copyEventSnapshot({ kind: 'drained', count: 0 })) + this.#changes.dispose() + } } diff --git a/apps/buddy/service/src/automations/AutomationEvents.ts b/apps/buddy/service/src/automations/AutomationEvents.ts new file mode 100644 index 00000000..19865f26 --- /dev/null +++ b/apps/buddy/service/src/automations/AutomationEvents.ts @@ -0,0 +1,40 @@ +import type { Automation, AutomationOccurrence } from '../../../shared/automation' +import type { EventSnapshot } from '../../../shared/events/eventTypes' + +export type AutomationFact = { automationId: string } & ( + | { kind: 'definition.created' | 'definition.updated' | 'definition.paused' | 'definition.resumed' | 'definition.deleted' | 'definition.blocked', definitionRevision: number } + | { kind: 'schedule.advanced', nextRunAt: string | null, status: Automation['status'] } + | { kind: 'occurrence.queued', occurrenceId: string, triggerKind: AutomationOccurrence['triggerKind'] } + | { kind: 'occurrence.finished', occurrenceId: string, status: AutomationOccurrence['status'], errorCode: string | null } + | { kind: 'occurrence.deleted', occurrenceId: string, conversationId: string | null, runId: string | null } + | { kind: 'lease.acquired', occurrenceId: string, leaseOwner: string, leaseExpiresAt: string } + | { kind: 'occurrence.bound', occurrenceId: string, conversationId: string, branchId: string, runId: string } + | { kind: 'task.created' | 'branch.created' | 'run.queued', occurrenceId: string, conversationId: string, branchId: string, runId: string } + | { kind: 'message.created', occurrenceId: string, conversationId: string, branchId: string, runId: string, messageId: string } + | { kind: 'definition.last_run_changed', lastRunAt: string } +) + +export type AutomationCommit = EventSnapshot<{ + operationId: string + revision: number + facts: readonly AutomationFact[] +}> + +export function definitionFact(kind: Extract['kind'], automation: Automation): AutomationFact { + return { kind, automationId: automation.id, definitionRevision: automation.revision } +} + +export function cancelledOccurrenceFacts(automationId: string, occurrenceIds: readonly string[]): AutomationFact[] { + return occurrenceIds.map(occurrenceId => ({ kind: 'occurrence.finished', automationId, occurrenceId, status: 'cancelled', errorCode: null })) +} + +export function occurrenceFact(occurrence: AutomationOccurrence): AutomationFact { + return occurrence.status === 'queued' + ? { kind: 'occurrence.queued', automationId: occurrence.automationId, occurrenceId: occurrence.id, triggerKind: occurrence.triggerKind } + : { kind: 'occurrence.finished', automationId: occurrence.automationId, occurrenceId: occurrence.id, status: occurrence.status, errorCode: occurrence.errorCode } +} + +export function affectsAutomationSchedule(commit: AutomationCommit): boolean { + return commit.facts.some(fact => ('definitionRevision' in fact) + || (fact.kind === 'occurrence.queued' && fact.triggerKind === 'manual')) +} diff --git a/apps/buddy/service/src/automations/AutomationOccurrenceLifecycleService.ts b/apps/buddy/service/src/automations/AutomationOccurrenceLifecycleService.ts index 803f387f..b4c43c71 100644 --- a/apps/buddy/service/src/automations/AutomationOccurrenceLifecycleService.ts +++ b/apps/buddy/service/src/automations/AutomationOccurrenceLifecycleService.ts @@ -1,6 +1,10 @@ import type { ConversationLifecycleService } from '../conversations/ConversationLifecycleService' import type { AttentionNotificationService } from '../notifications/AttentionNotificationService' import type { AutomationService } from './AutomationService' +import { randomUUID } from 'node:crypto' +import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export interface AutomationOccurrenceDeletionResult { automationId: string | null @@ -11,55 +15,90 @@ export interface AutomationOccurrenceLifecycleServiceOptions { automations: AutomationService conversationLifecycle: Pick notifications: Pick - onChanged?: (automationId: string) => void + onObserverError?: (error: unknown) => void } +export type AutomationOccurrenceCleanup = Readonly<{ operationId: string, occurrenceId: string, automationId: string, conversationId: string | null, status: 'started' | 'completed' | 'failed', errorCode?: string }> + export class AutomationOccurrenceLifecycleService { readonly #automations: AutomationService readonly #conversationLifecycle: AutomationOccurrenceLifecycleServiceOptions['conversationLifecycle'] readonly #notifications: AutomationOccurrenceLifecycleServiceOptions['notifications'] - readonly #onChanged: NonNullable + readonly #cleanup: Emitter + readonly #pending = new Map>() + readonly #completed = new Set() + #stopping = false + readonly onDidCleanup: Emitter['event'] constructor(options: AutomationOccurrenceLifecycleServiceOptions) { this.#automations = options.automations this.#conversationLifecycle = options.conversationLifecycle this.#notifications = options.notifications - this.#onChanged = options.onChanged ?? (() => {}) + this.#cleanup = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCleanup = this.#cleanup.event } - async deleteConversation(conversationId: string): Promise { - const occurrence = this.#automations.getOccurrenceByConversationId(conversationId) - const occurrenceDeleted = occurrence - ? this.#deleteOccurrenceRecord(occurrence.id, occurrence.runId) - : false - const conversationDeleted = await this.#conversationLifecycle.delete(conversationId) - if (occurrence) - this.#onChanged(occurrence.automationId) - return { - automationId: occurrence?.automationId ?? null, - deleted: occurrenceDeleted || conversationDeleted, - } + deleteConversation(conversationId: string): Promise { + return this.#run(`conversation:${conversationId}`, async () => { + const occurrence = this.#automations.getOccurrenceDeletionByConversation(conversationId) + if (occurrence) + return this.#deleteOccurrence(occurrence.id) + return { automationId: null, deleted: await this.#conversationLifecycle.delete(conversationId) } + }) } - async deleteOccurrence(occurrenceId: string): Promise { - const occurrence = this.#automations.getOccurrence(occurrenceId) + deleteOccurrence(occurrenceId: string): Promise { + const occurrence = this.#automations.getOccurrenceForDeletion(occurrenceId) + return this.#run(occurrence?.conversationId ? `conversation:${occurrence.conversationId}` : `occurrence:${occurrenceId}`, () => this.#deleteOccurrence(occurrenceId)) + } + + async #deleteOccurrence(occurrenceId: string): Promise { + const occurrence = this.#automations.getOccurrenceForDeletion(occurrenceId) if (!occurrence) return { automationId: null, deleted: false } - const occurrenceDeleted = this.#deleteOccurrenceRecord(occurrence.id, occurrence.runId) - const conversationDeleted = occurrence.conversationId - ? await this.#conversationLifecycle.delete(occurrence.conversationId) - : false - this.#onChanged(occurrence.automationId) - return { - automationId: occurrence.automationId, - deleted: occurrenceDeleted || conversationDeleted, + if (this.#completed.has(occurrence.id)) + return { automationId: occurrence.automationId, deleted: false } + const occurrenceDeleted = this.#automations.markOccurrenceDeleted(occurrence.id) + const identity = { operationId: randomUUID(), occurrenceId: occurrence.id, automationId: occurrence.automationId, conversationId: occurrence.conversationId } + this.#cleanup.fire(copyEventSnapshot({ ...identity, status: 'started' })) + try { + if (occurrence.runId) + await this.#notifications.removeAutomationRun(occurrence.runId) + const conversationDeleted = occurrence.conversationId + ? await this.#conversationLifecycle.delete(occurrence.conversationId) + : false + this.#completed.add(occurrence.id) + this.#cleanup.fire(copyEventSnapshot({ ...identity, status: 'completed' })) + return { automationId: occurrence.automationId, deleted: occurrenceDeleted || conversationDeleted } + } + catch (error) { + this.#cleanup.fire(copyEventSnapshot({ ...identity, status: 'failed', errorCode: readDiagnosticErrorCode(error) })) + throw error } } - #deleteOccurrenceRecord(occurrenceId: string, runId: string | null): boolean { - const deleted = this.#automations.markOccurrenceDeleted(occurrenceId) - if (runId) - this.#notifications.removeAutomationRun(runId) - return deleted + async recoverPendingDeletions(): Promise { + for (const occurrence of this.#automations.listPendingDeletions()) + await this.deleteOccurrence(occurrence.id) + } + + async dispose(): Promise { + this.#stopping = true + await Promise.allSettled([...this.#pending.values()]) + this.#cleanup.dispose() + this.#completed.clear() + } + + #run(key: string, operation: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new Error('Automation occurrence lifecycle is stopped')) + const previous = this.#pending.get(key) + if (previous) + return previous + const accepted = Promise.withResolvers() + this.#pending.set(key, accepted.promise) + void accepted.promise.finally(() => this.#pending.delete(key)).catch(() => {}) + void operation().then(accepted.resolve, accepted.reject) + return accepted.promise } } diff --git a/apps/buddy/service/src/automations/AutomationScheduler.ts b/apps/buddy/service/src/automations/AutomationScheduler.ts index 02e32e72..0082bfa1 100644 --- a/apps/buddy/service/src/automations/AutomationScheduler.ts +++ b/apps/buddy/service/src/automations/AutomationScheduler.ts @@ -4,6 +4,8 @@ import type { AutomationClock } from './AutomationScheduleEvaluator' import type { AutomationService } from './AutomationService' import { randomUUID } from 'node:crypto' import { Temporal } from '../../../shared/automation/temporal' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { findNextAutomationOccurrence, systemAutomationClock } from './AutomationScheduleEvaluator' export const AUTOMATION_POLL_INTERVAL_MS = 30_000 @@ -19,55 +21,83 @@ export interface AutomationSchedulerOptions { clock?: AutomationClock createOwnerId?: () => string dispatch: (occurrence: AutomationOccurrenceRecord) => Promise - onChanged?: (automationId: string) => void + onObserverError?: (error: unknown) => void } export class AutomationScheduler { readonly #automationService: AutomationService readonly #clock: AutomationClock readonly #dispatch: AutomationSchedulerOptions['dispatch'] - readonly #onChanged: NonNullable + readonly #changes: Emitter> + readonly onDidChange: Emitter>['event'] readonly #owner: string readonly #activeDispatches = new Map>() #disposed = false #pollTimer: ReturnType | null = null #scan: Promise | null = null #started = false + #state: 'starting' | 'ready' | 'degraded' | 'stopping' | 'drained' = 'starting' + #starting: Promise | null = null + #scanRequested = false + readonly #dispatchFailures = new Map() constructor(options: AutomationSchedulerOptions) { this.#automationService = options.automationService this.#clock = options.clock ?? systemAutomationClock this.#dispatch = options.dispatch - this.#onChanged = options.onChanged ?? (() => {}) + this.#changes = new Emitter(options.onObserverError ?? (() => {})) + this.onDidChange = this.#changes.event this.#owner = (options.createOwnerId ?? randomUUID)() } + get state() { return this.#state } + async dispose(): Promise { - this.#disposed = true + if (!this.#disposed) { + this.#disposed = true + this.#publish('stopping') + } if (this.#pollTimer) { clearInterval(this.#pollTimer) this.#pollTimer = null } - await this.#scan + await this.#scan?.catch(() => {}) } async settle(): Promise { - const results = await Promise.allSettled([...this.#activeDispatches.values()]) - const failures = results.filter(result => result.status === 'rejected').map(result => result.reason) - if (failures.length) - throw new AggregateError(failures, 'Automation dispatch cleanup failed') + await Promise.allSettled([...this.#activeDispatches.values()]) + this.#reconcileDispatchFailures() + if (this.#dispatchFailures.size) { + this.#publish('degraded', 'AUTOMATION_DISPATCH_FAILED') + throw new AggregateError([...this.#dispatchFailures.values()].map(item => item.error), 'Automation dispatch cleanup failed') + } + if (this.#disposed) { + this.#publish('drained') + this.#changes.dispose() + } } - async start(): Promise { - if (this.#started) - return - this.#started = true - await this.#requestScan() + start(): Promise { if (this.#disposed) - return - this.#pollTimer = setInterval(() => { - void this.wake() - }, AUTOMATION_POLL_INTERVAL_MS) + return Promise.reject(new Error('Automation scheduler is stopped')) + if (this.#started) + return Promise.resolve() + if (this.#starting) + return this.#starting + this.#publish('starting') + const starting = this.#requestScan().then(() => { + if (this.#disposed) + return + this.#started = true + this.#pollTimer = setInterval(() => { + void this.wake().catch(() => {}) + }, AUTOMATION_POLL_INTERVAL_MS) + }).finally(() => { + if (this.#starting === starting) + this.#starting = null + }) + this.#starting = starting + return starting } wake(): Promise { @@ -77,9 +107,20 @@ export class AutomationScheduler { } #requestScan(): Promise { + this.#scanRequested = true if (this.#scan) return this.#scan - const scan = this.#runScan().finally(() => { + const scan = Promise.resolve().then(async () => { + while (this.#scanRequested && !this.#disposed) { + this.#scanRequested = false + await this.#runScan() + } + if (!this.#disposed) + this.#publish(this.#dispatchFailures.size ? 'degraded' : 'ready') + }).catch((error) => { + this.#publish('degraded', 'AUTOMATION_SCAN_FAILED') + throw error + }).finally(() => { if (this.#scan === scan) this.#scan = null }) @@ -87,9 +128,15 @@ export class AutomationScheduler { return scan } + #publish(state: 'starting' | 'ready' | 'degraded' | 'stopping' | 'drained', errorCode?: string): void { + this.#state = state + this.#changes.fire(copyEventSnapshot({ state, activeCount: this.#activeDispatches.size, ...(errorCode ? { errorCode } : {}) })) + } + async #runScan(): Promise { if (this.#disposed) return + this.#reconcileDispatchFailures() const now = this.#clock.now() const nowText = formatInstant(now) for (let batch = 0; batch < 100 && !this.#disposed; batch += 1) { @@ -139,9 +186,7 @@ export class AutomationScheduler { expectedRevision: automation.revision, scheduledFor: formatInstant(latest), } - const occurrence = this.#automationService.claimScheduled(common) - if (occurrence) - this.#onChanged(automation.id) + this.#automationService.claimScheduled(common) } #settleMissed( @@ -152,7 +197,7 @@ export class AutomationScheduler { ): void { if (!automation.nextRunAt) return - const occurrence = this.#automationService.settleScheduled({ + this.#automationService.settleScheduled({ advanceAfter: formatInstant(now), automationId: automation.id, coalescedMissedCount: 0, @@ -162,8 +207,14 @@ export class AutomationScheduler { scheduledFor: automation.nextRunAt, status, }) - if (occurrence) - this.#onChanged(automation.id) + } + + #reconcileDispatchFailures(): void { + for (const [id, failure] of this.#dispatchFailures) { + const occurrence = this.#automationService.getOccurrence(id) + if (!occurrence || this.#automationService.getActiveOccurrence(failure.automationId)?.id !== id) + this.#dispatchFailures.delete(id) + } } #dispatchAvailable(): void { @@ -181,20 +232,36 @@ export class AutomationScheduler { }) for (const occurrence of leased) { const dispatch = Promise.resolve() - .then(() => this.#dispatch(occurrence)) - .catch(() => { - this.#automationService.finishQueued({ - errorCode: 'RUNTIME_RESTARTED', - id: occurrence.id, - leaseOwner: this.#owner, - status: 'skipped', - }) + .then(() => this.#disposed ? undefined : this.#dispatch(occurrence)) + .catch((error) => { + if (this.#disposed && error instanceof Error && error.name === 'AbortError') + return + this.#publish('degraded', 'AUTOMATION_DISPATCH_FAILED') + try { + const finished = this.#automationService.finishQueued({ + errorCode: 'RUNTIME_RESTARTED', + id: occurrence.id, + leaseOwner: this.#owner, + status: 'skipped', + }) + if (!finished && this.#automationService.getActiveOccurrence(occurrence.automationId)?.id === occurrence.id) + this.#dispatchFailures.set(occurrence.id, { automationId: occurrence.automationId, error }) + } + catch (cleanup) { + this.#dispatchFailures.set(occurrence.id, { automationId: occurrence.automationId, error: cleanup }) + } }) .then(() => {}) .finally(() => { this.#activeDispatches.delete(occurrence.id) - if (!this.#disposed) - this.#dispatchAvailable() + if (!this.#disposed) { + try { + this.#dispatchAvailable() + } + catch { + this.#publish('degraded', 'AUTOMATION_SCAN_FAILED') + } + } }) this.#activeDispatches.set(occurrence.id, dispatch) } diff --git a/apps/buddy/service/src/automations/AutomationService.ts b/apps/buddy/service/src/automations/AutomationService.ts index 66da0b28..f0a2f854 100644 --- a/apps/buddy/service/src/automations/AutomationService.ts +++ b/apps/buddy/service/src/automations/AutomationService.ts @@ -7,10 +7,12 @@ import type { CreateAutomationRequest, UpdateAutomationRequest, } from '../../../shared/automation' +import type { AutomationDefinitionCommit } from '../storage/automationDefinitionCommandRepository' import type { AutomationMutationOperation } from '../storage/automationMutationRequestRepository' import type { AutomationOccurrenceRecord } from '../storage/automationOccurrenceRecord' import type { AutomationCursor, AutomationPageRecord } from '../storage/automationPage' import type { AutomationRepositories } from '../storage/automationRepository' +import type { AutomationCommit, AutomationFact } from './AutomationEvents' import type { AutomationClock } from './AutomationScheduleEvaluator' import { Buffer } from 'node:buffer' import { createHash, randomUUID } from 'node:crypto' @@ -21,7 +23,10 @@ import { automationMutationRequestSchemas, } from '../../../shared/automation' import { Temporal } from '../../../shared/automation/temporal' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { AutomationRepositoryError } from '../storage/automationRepositoryError' +import { cancelledOccurrenceFacts, definitionFact, occurrenceFact } from './AutomationEvents' import { findNextAutomationOccurrence, previewAutomationSchedule, @@ -42,6 +47,7 @@ export interface AutomationServiceOptions { clock?: AutomationClock createId?: () => string repositories: AutomationRepositories + onObserverError?: (error: unknown) => void } export class AutomationServiceError extends Error { @@ -60,6 +66,10 @@ export class AutomationService { readonly #definitions: AutomationRepositories['definitions'] readonly #mutations: AutomationRepositories['mutations'] readonly #occurrences: AutomationRepositories['occurrences'] + readonly #committed: Emitter + readonly onDidCommit: Emitter['event'] + #revision = 0 + #disposed = false constructor(options: AutomationServiceOptions) { this.#clock = options.clock ?? systemAutomationClock @@ -67,6 +77,8 @@ export class AutomationService { this.#definitions = options.repositories.definitions this.#mutations = options.repositories.mutations this.#occurrences = options.repositories.occurrences + this.#committed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCommit = this.#committed.event } claimScheduled(input: { @@ -92,7 +104,7 @@ export class AutomationService { automation.timing, Temporal.Instant.from(advanceAfter), ) - return this.#occurrences.claimScheduled({ + const occurrence = this.#occurrences.claimScheduled({ automationId: automation.id, coalescedMissedCount: input.coalescedMissedCount, expectedNextRunAt, @@ -102,6 +114,9 @@ export class AutomationService { queuedAt: this.#now(), scheduledFor: normalizeInstant(input.scheduledFor), }) + if (occurrence) + this.#publish([occurrenceFact(occurrence), { kind: 'schedule.advanced', automationId: automation.id, nextRunAt: next ? formatInstant(next) : null, status: next ? 'active' : 'completed' }]) + return occurrence } block(input: { @@ -109,29 +124,30 @@ export class AutomationService { expectedRevision: number reason: NonNullable }): Automation | null { - return this.#definitions.block({ + const result = this.#definitions.block({ automationId: input.automationId, blockedAt: this.#now(), expectedRevision: input.expectedRevision, reason: input.reason, }) + return result ? this.#acceptDefinition('definition.blocked', result) : null } blockPinnedModel(providerId: string, modelId?: string): Automation[] { - return this.#definitions.blockActiveByPinnedModel({ + return this.#acceptDefinitions('definition.blocked', this.#definitions.blockActiveByPinnedModel({ blockedAt: this.#now(), modelId: modelId ? z.string().trim().min(1).max(256).parse(modelId) : undefined, providerId: z.string().trim().min(1).max(256).parse(providerId), - }) + })) } blockSpace(spaceId: string): Automation[] { - return this.#definitions.blockActiveBySpace({ + return this.#acceptDefinitions('definition.blocked', this.#definitions.blockActiveBySpace({ blockedAt: this.#now(), spaceId: z.string().trim().min(1).max(256).parse(spaceId), - }) + })) } create(input: CreateAutomationRequest): Automation { @@ -151,10 +167,10 @@ export class AutomationService { revision: 1, updatedAt: now, }) - return this.#mapRepositoryError(() => this.#definitions.create( + return this.#acceptDefinition('definition.created', this.#mapRepositoryError(() => this.#definitions.create( automation, mutation, - )) + ))) } delete(input: AutomationMutationTargetRequest): Automation { @@ -167,7 +183,7 @@ export class AutomationService { if (replay) return replay const existing = this.#requireAutomation(request.automationId) - return this.#mapRepositoryError(() => this.#definitions.replace({ + return this.#acceptDefinition('definition.deleted', this.#mapRepositoryError(() => this.#definitions.replace({ automation: { ...existing, blockedReason: null, @@ -178,7 +194,7 @@ export class AutomationService { }, cancelQueued: true, expectedRevision: request.expectedRevision, - }, mutation)) + }, mutation))) } get(id: string): Automation | null { @@ -201,6 +217,18 @@ export class AutomationService { ) } + getOccurrenceForDeletion(id: string): AutomationOccurrenceRecord | null { + return this.#occurrences.findOccurrenceForDeletion(id) + } + + getOccurrenceDeletionByConversation(conversationId: string): AutomationOccurrenceRecord | null { + return this.#occurrences.findOccurrenceDeletionByConversation(conversationId) + } + + listPendingDeletions(): AutomationOccurrenceRecord[] { + return this.#occurrences.listPendingDeletions() + } + finishQueued(input: { errorCode: AutomationErrorCode errorSummary?: string | null @@ -208,10 +236,13 @@ export class AutomationService { leaseOwner?: string | null status: 'cancelled' | 'expired' | 'skipped' }): AutomationOccurrenceRecord | null { - return this.#occurrences.finishQueued({ + const occurrence = this.#occurrences.finishQueued({ ...input, finishedAt: this.#now(), }) + if (occurrence) + this.#publish([occurrenceFact(occurrence)]) + return occurrence } finishQueuedAndBlock(input: { @@ -224,10 +255,18 @@ export class AutomationService { automation: Automation | null occurrence: AutomationOccurrenceRecord } | null { - return this.#occurrences.finishQueuedAndBlock({ + const result = this.#occurrences.finishQueuedAndBlock({ ...input, finishedAt: this.#now(), }) + if (result) { + this.#publish([ + occurrenceFact(result.occurrence), + ...cancelledOccurrenceFacts(input.automationId, result.cancelledOccurrenceIds), + ...(result.automation ? [definitionFact('definition.blocked', result.automation)] : []), + ]) + } + return result } leaseQueued(input: { @@ -236,6 +275,7 @@ export class AutomationService { now: string owner: string }): AutomationOccurrenceRecord[] { + this.#requireOpen() const owner = z.string().trim().min(1).max(128).parse(input.owner) const limit = z.number().int().min(1).max(100).parse(input.limit) const now = normalizeInstant(input.now) @@ -246,7 +286,9 @@ export class AutomationService { ) <= 0) { throw new AutomationServiceError('AUTOMATION_CONFLICT') } - return this.#occurrences.leaseQueued({ leaseExpiresAt, limit, now, owner }) + const leased = this.#occurrences.leaseQueued({ leaseExpiresAt, limit, now, owner }) + this.#publish(leased.map(occurrence => ({ kind: 'lease.acquired', automationId: occurrence.automationId, occurrenceId: occurrence.id, leaseOwner: owner, leaseExpiresAt }))) + return leased } list(input: { @@ -286,10 +328,18 @@ export class AutomationService { } markOccurrenceDeleted(id: string): boolean { - return this.#occurrences.markOccurrenceDeleted( + const result = this.#occurrences.markOccurrenceDeleted( z.string().trim().min(1).max(256).parse(id), this.#now(), ) + if (!result) + return false + const occurrence = result.occurrence + this.#publish([ + { kind: 'occurrence.deleted', automationId: occurrence.automationId, occurrenceId: occurrence.id, conversationId: occurrence.conversationId, runId: occurrence.runId }, + ...(result.previousStatus === 'queued' ? [occurrenceFact(occurrence)] : []), + ]) + return true } pause(input: AutomationMutationTargetRequest): Automation { @@ -302,7 +352,7 @@ export class AutomationService { if (replay) return replay const existing = this.#requireAutomation(request.automationId) - return this.#mapRepositoryError(() => this.#definitions.replace({ + return this.#acceptDefinition('definition.paused', this.#mapRepositoryError(() => this.#definitions.replace({ automation: { ...existing, blockedReason: null, @@ -313,7 +363,7 @@ export class AutomationService { }, cancelQueued: true, expectedRevision: request.expectedRevision, - }, mutation)) + }, mutation))) } resume(input: AutomationMutationTargetRequest): Automation { @@ -334,11 +384,11 @@ export class AutomationService { revision: existing.revision + 1, updatedAt: now, }) - return this.#mapRepositoryError(() => this.#definitions.replace({ + return this.#acceptDefinition('definition.resumed', this.#mapRepositoryError(() => this.#definitions.replace({ automation: resumed, cancelQueued: false, expectedRevision: request.expectedRevision, - }, mutation)) + }, mutation))) } runNow(input: AutomationMutationTargetRequest): AutomationRunNowResult { @@ -350,13 +400,16 @@ export class AutomationService { ) if (replay) return replay - return this.#mapRepositoryError(() => this.#occurrences.createManualOccurrence({ + const committed = this.#mapRepositoryError(() => this.#occurrences.createManualOccurrence({ automationId: request.automationId, expectedRevision: request.expectedRevision, id: this.#createId(), queuedAt: now, scheduledFor: now, }, mutation)) + if (committed.committed) + this.#publish([occurrenceFact(committed.result.occurrence)]) + return committed.result } settleScheduled(input: { @@ -383,7 +436,7 @@ export class AutomationService { automation.timing, Temporal.Instant.from(normalizeInstant(input.advanceAfter)), ) - return this.#occurrences.settleScheduled({ + const occurrence = this.#occurrences.settleScheduled({ automationId: automation.id, coalescedMissedCount: input.coalescedMissedCount, errorCode: input.errorCode, @@ -396,6 +449,9 @@ export class AutomationService { scheduledFor: normalizeInstant(input.scheduledFor), status: input.status, }) + if (occurrence) + this.#publish([occurrenceFact(occurrence), { kind: 'schedule.advanced', automationId: automation.id, nextRunAt: next ? formatInstant(next) : null, status: next ? 'active' : 'completed' }]) + return occurrence } update(input: UpdateAutomationRequest): Automation { @@ -416,11 +472,38 @@ export class AutomationService { revision: existing.revision + 1, updatedAt: now, }) - return this.#mapRepositoryError(() => this.#definitions.replace({ + return this.#acceptDefinition('definition.updated', this.#mapRepositoryError(() => this.#definitions.replace({ automation: updated, cancelQueued: false, expectedRevision: request.expectedRevision, - }, mutation)) + }, mutation))) + } + + dispose(): void { + this.#disposed = true + this.#committed.dispose() + } + + #acceptDefinition(kind: Parameters[0], result: AutomationDefinitionCommit): Automation { + return this.#acceptDefinitions(kind, [result])[0]! + } + + #acceptDefinitions(kind: Parameters[0], results: readonly AutomationDefinitionCommit[]): Automation[] { + this.#publish(results.flatMap(result => result.committed + ? [definitionFact(kind, result.automation), ...cancelledOccurrenceFacts(result.automation.id, result.cancelledOccurrenceIds)] + : [])) + return results.map(result => result.automation) + } + + #publish(facts: readonly AutomationFact[]): void { + if (!facts.length) + return + this.#committed.fire(copyEventSnapshot({ operationId: randomUUID(), revision: ++this.#revision, facts })) + } + + #requireOpen(): void { + if (this.#disposed) + throw new Error('Automation service is stopped') } #buildAutomation(input: { @@ -467,6 +550,7 @@ export class AutomationService { } #now(): string { + this.#requireOpen() return formatInstant(this.#clock.now()) } diff --git a/apps/buddy/service/src/automations/AutomationTurnService.ts b/apps/buddy/service/src/automations/AutomationTurnService.ts new file mode 100644 index 00000000..1056c7eb --- /dev/null +++ b/apps/buddy/service/src/automations/AutomationTurnService.ts @@ -0,0 +1,50 @@ +import type { AutomationTurnRepository, BindAutomationTurnInput } from '../storage/automationTurnRepository' +import type { AutomationCommit, AutomationFact } from './AutomationEvents' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { occurrenceFact } from './AutomationEvents' + +export class AutomationTurnService implements AutomationTurnRepository { + readonly #repository: AutomationTurnRepository + readonly #committed: Emitter + readonly onDidCommit: Emitter['event'] + #revision = 0 + #disposed = false + + constructor(repository: AutomationTurnRepository, onObserverError: (error: unknown) => void = () => {}) { + this.#repository = repository + this.#committed = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + } + + bind(input: BindAutomationTurnInput) { + if (this.#disposed) + throw new Error('Automation turn service is stopped') + const binding = this.#repository.bind(input) + const facts: AutomationFact[] = [] + if (binding.kind === 'bound') { + const identity = { automationId: binding.occurrence.automationId, occurrenceId: binding.occurrence.id, conversationId: binding.conversation.id, branchId: binding.run.branchId, runId: binding.run.id } + facts.push( + { kind: 'occurrence.bound', ...identity }, + { kind: 'task.created', ...identity }, + { kind: 'branch.created', ...identity }, + { kind: 'message.created', ...identity, messageId: binding.run.triggeringMessageId }, + { kind: 'run.queued', ...identity }, + ) + if (binding.lastRunChanged) + facts.push({ kind: 'definition.last_run_changed', automationId: identity.automationId, lastRunAt: input.boundAt }) + } + else if (binding.kind === 'overlap_skipped') { + facts.push(occurrenceFact(binding.occurrence)) + } + if (facts.length) + this.#committed.fire(copyEventSnapshot({ operationId: binding.kind === 'bound' ? binding.run.id : randomUUID(), revision: ++this.#revision, facts })) + return binding + } + + dispose(): void { + this.#disposed = true + this.#committed.dispose() + } +} diff --git a/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts index b925e65e..da923899 100644 --- a/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts @@ -1,9 +1,13 @@ import type { DatabaseSync } from 'node:sqlite' import type { AutomationDefinitionDraft } from '../../../../shared/automation' -import { afterEach, describe, expect, it } from 'vitest' +import type { RunSqlReconciliation } from '../../runs/RunLifecycleService' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' import { createAutomationRepositories } from '../../storage/automationRepository' +import { createAutomationTurnRepository } from '../../storage/automationTurnRepository' import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' import { createSpaceRepository } from '../../storage/spaceRepository' import { AutomationChangeCoordinator } from '../AutomationChangeCoordinator' import { AutomationService } from '../AutomationService' @@ -16,6 +20,34 @@ afterEach(() => { }) describe('automationChangeCoordinator', () => { + it('retains failed run refreshes for bounded reconciliation without converting them into scheduler wakes', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const service = new AutomationService({ clock: { now: () => Temporal.Instant.from('2026-08-24T00:00:00.000Z') }, repositories: createAutomationRepositories(database) }) + const automation = service.create({ requestId: 'create-refresh', draft: dailyDraft() }) + service.runNow({ automationId: automation.id, expectedRevision: 1, requestId: 'run-refresh' }) + const occurrence = service.leaseQueued({ now: '2026-08-24T00:00:00.000Z', leaseExpiresAt: '2026-08-24T00:01:00.000Z', limit: 1, owner: 'scheduler-refresh' })[0]! + createAutomationTurnRepository(database).bind({ boundAt: '2026-08-24T00:00:10.000Z', branchId: 'branch-refresh', conversationId: 'conversation-refresh', contextWindow: 100_000, maxTokens: 8_000, executionContext: null, leaseOwner: 'scheduler-refresh', messageId: 'message-refresh', model: 'model', occurrenceId: occurrence.id, provider: 'provider', reasoning: null, runId: 'run-refresh', spaceId: null }) + const reconciled = new Emitter(() => {}) + const runs = createRunRepository(database) + const notifications: string[] = [] + const wakeScheduler = vi.fn() + const coordinator = new AutomationChangeCoordinator({ service, notify: id => notifications.push(id), wakeScheduler, runChanges: { runs, eventLog: { onDidCommit: () => ({ dispose: () => {} }) }, lifecycle: { onDidReconcile: reconciled.event } } }) + const broken = vi.spyOn(runs, 'findById').mockImplementation(() => { + throw new Error('temporary read failure') + }) + reconciled.fire({ runId: 'run-refresh', conversationId: 'conversation-refresh', branchId: 'branch-refresh', status: 'failed', completedAt: '2026-08-24T00:00:20.000Z', errorCode: 'RUNTIME_RESTARTED' }) + expect(coordinator.state).toBe('degraded') + expect(broken).toHaveBeenCalledTimes(3) + expect(notifications).toEqual([]) + broken.mockRestore() + coordinator.reconcile() + expect(coordinator.state).toBe('ready') + expect(notifications).toEqual([automation.id]) + expect(wakeScheduler).not.toHaveBeenCalled() + await coordinator.dispose() + }) + it('reconciles unavailable dependencies and publishes one scheduler wake', () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) diff --git a/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts index bfe39074..e7add684 100644 --- a/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts @@ -1,7 +1,8 @@ import type { DatabaseSync } from 'node:sqlite' import type { AutomationModelTarget } from '../../../../shared/automation' -import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AutomationCommit } from '../AutomationEvents' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createAutomationRepositories } from '../../storage/automationRepository' import { createAutomationTurnRepository } from '../../storage/automationTurnRepository' import { createConversationRepository } from '../../storage/conversationRepository' @@ -12,6 +13,7 @@ import { createSpaceRepository } from '../../storage/spaceRepository' import { AgentTaskAutomationAction } from '../AgentTaskAutomationAction' import { AutomationDispatcher } from '../AutomationDispatcher' import { AutomationService } from '../AutomationService' +import { AutomationTurnService } from '../AutomationTurnService' const databases: DatabaseSync[] = [] @@ -21,6 +23,58 @@ afterEach(() => { }) describe('automationDispatcher', () => { + it('keeps unbound work queued when shutdown races model preparation and drains only after preparation returns', async () => { + const fixture = createFixture() + const occurrence = fixture.queue({ model: { mode: 'default' } }) + const entered = Promise.withResolvers() + const ready = Promise.withResolvers() + const dispatcher = fixture.dispatcher({ + resolveModel: async () => { + entered.resolve() + await ready.promise + return { contextWindow: 200_000, maxTokens: 32_000, modelId: 'model-1', providerId: 'provider-1', reasoning: null } + }, + launchTurn: async () => { throw new Error('Stopped preparation must not launch') }, + }) + const states: string[] = [] + dispatcher.onDidChange(event => states.push(event.kind)) + const operation = dispatcher.dispatch(occurrence) + const rejected = expect(operation).rejects.toMatchObject({ name: 'AbortError' }) + await entered.promise + const stopping = dispatcher.dispose() + expect(states).toEqual(['started', 'stopping']) + ready.resolve() + await rejected + await stopping + expect(states).toEqual(['started', 'stopping', 'failed', 'drained']) + expect(fixture.runs.listRecent()).toEqual([]) + expect(fixture.service.getOccurrence(occurrence.id)).toMatchObject({ status: 'queued', runId: null }) + }) + + it('publishes the entire bound transaction before launch and preserves it when launch rejects', async () => { + const fixture = createFixture() + const occurrence = fixture.queue({ model: { mode: 'default' } }) + const commits: AutomationCommit[] = [] + fixture.turns.onDidCommit(event => commits.push(event)) + const dispatcher = fixture.dispatcher({ launchTurn: async (runId) => { + expect(commits).toHaveLength(1) + expect(fixture.runs.findById(runId)?.status).toBe('queued') + throw new Error('unresolved launcher failure') + } }) + await expect(dispatcher.dispatch(occurrence)).rejects.toThrow('unresolved launcher failure') + expect(commits[0]!.facts.map(fact => fact.kind)).toEqual([ + 'occurrence.bound', + 'task.created', + 'branch.created', + 'message.created', + 'run.queued', + 'definition.last_run_changed', + ]) + expect(fixture.runs.listRecent()[0]?.status).toBe('queued') + expect(fixture.service.getOccurrence(occurrence.id)?.status).toBe('bound') + await dispatcher.dispose() + }) + it('binds and runs the frozen snapshot through a background Buddy turn', async () => { const fixture = createFixture() const occurrence = fixture.queue({ @@ -232,6 +286,7 @@ function createFixture() { const spaces = createSpaceRepository(database) const runInputs = createRunInputRepository(database) const runs = createRunRepository(database) + const turns = new AutomationTurnService(createAutomationTurnRepository(database)) const queue = (input: { executionProfile?: 'full_access' | 'workspace_write' model: AutomationModelTarget @@ -270,9 +325,10 @@ function createFixture() { } return { conversations, + turns, dispatcher: (overrides: { cancelRun?: (runId: string, errorCode: string) => Promise - launchTurn: (runId: string) => Promise<{ + launchTurn: (runId: string, signal?: AbortSignal) => Promise<{ completion: Promise & {}> runId: string }> @@ -305,7 +361,7 @@ function createFixture() { : null }), runTimeoutMs: overrides.runTimeoutMs, - turns: createAutomationTurnRepository(database), + turns, })), queue, runInputs, diff --git a/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts index 17fc7609..b578ac38 100644 --- a/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createAutomationRepositories } from '../../storage/automationRepository' import { createAutomationTurnRepository } from '../../storage/automationTurnRepository' +import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' import { AutomationOccurrenceLifecycleService } from '../AutomationOccurrenceLifecycleService' import { AutomationService } from '../AutomationService' @@ -15,6 +17,58 @@ afterEach(() => { }) describe('automationOccurrenceLifecycleService', () => { + it('recovers a persisted deletion after notification cleanup fails without repeating the tombstone fact', async () => { + const f = createBoundFixture() + const commits: string[] = [] + f.automations.onDidCommit(event => commits.push(...event.facts.map(fact => fact.kind))) + const failed = new AutomationOccurrenceLifecycleService({ + automations: f.automations, + conversationLifecycle: { + delete: async () => { + throw new Error('must follow notification removal') + }, + }, + notifications: { + removeAutomationRun: () => { + throw new Error('notification store unavailable') + }, + }, + }) + const cleanup: string[] = [] + failed.onDidCleanup(event => cleanup.push(event.status)) + await expect(failed.deleteOccurrence(f.occurrence.id)).rejects.toThrow('notification store unavailable') + expect(f.automations.listHistory({}).items).toEqual([]) + expect(f.automations.listPendingDeletions().map(item => item.id)).toEqual([f.occurrence.id]) + expect(commits).toEqual(['occurrence.deleted']) + expect(cleanup).toEqual(['started', 'failed']) + await failed.dispose() + const recovered = new AutomationOccurrenceLifecycleService({ automations: f.automations, conversationLifecycle: { delete: async id => f.conversations.markDeleted(id, '2026-08-24T00:00:30.000Z') }, notifications: { removeAutomationRun: () => true } }) + await recovered.recoverPendingDeletions() + expect(f.automations.listPendingDeletions()).toEqual([]) + expect(f.conversations.isDeleted('conversation-recovery')).toBe(true) + expect(commits).toEqual(['occurrence.deleted']) + await expect(recovered.deleteOccurrence(f.occurrence.id)).resolves.toMatchObject({ deleted: false }) + await recovered.dispose() + }) + + it('shares deletion cleanup between history and conversation entry points and drains accepted work', async () => { + const f = createBoundFixture() + const gate = Promise.withResolvers() + const lifecycle = new AutomationOccurrenceLifecycleService({ automations: f.automations, conversationLifecycle: { delete: async () => gate.promise }, notifications: { removeAutomationRun: () => true } }) + const states: string[] = [] + lifecycle.onDidCleanup(event => states.push(event.status)) + const first = lifecycle.deleteOccurrence(f.occurrence.id) + const second = lifecycle.deleteConversation('conversation-recovery') + expect(first).toBe(second) + const stopping = lifecycle.dispose() + expect(states).toEqual(['started']) + gate.resolve(true) + await stopping + await expect(first).resolves.toMatchObject({ deleted: true }) + expect(states).toEqual(['started', 'completed']) + await expect(lifecycle.deleteOccurrence(f.occurrence.id)).rejects.toThrow('stopped') + }) + it('deletes a bound occurrence, its conversation, and its notification as one lifecycle', async () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) @@ -153,3 +207,15 @@ function incrementalIds(prefix: string): () => string { let next = 0 return () => `${prefix}-${++next}` } + +function createBoundFixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const automations = new AutomationService({ clock: { now: () => Temporal.Instant.from('2026-08-24T00:00:00.000Z') }, repositories: createAutomationRepositories(database) }) + const definition = automations.create({ requestId: 'create-recovery', draft: { executionProfile: 'workspace_write', model: { mode: 'default' }, name: 'Recovery', prompt: 'Run recovery fixture', spaceId: null, timing: { activeFrom: null, activeUntil: null, schedule: { cadence: 'daily', kind: 'calendar', localTime: '12:00' }, timezone: 'Asia/Shanghai' } } }) + automations.runNow({ automationId: definition.id, expectedRevision: 1, requestId: 'run-recovery' }) + const occurrence = automations.leaseQueued({ now: '2026-08-24T00:00:00.000Z', leaseExpiresAt: '2026-08-24T00:01:00.000Z', limit: 1, owner: 'scheduler-recovery' })[0]! + createAutomationTurnRepository(database).bind({ boundAt: '2026-08-24T00:00:10.000Z', branchId: 'branch-recovery', conversationId: 'conversation-recovery', contextWindow: 100_000, maxTokens: 8_000, executionContext: null, leaseOwner: 'scheduler-recovery', messageId: 'message-recovery', model: 'model', occurrenceId: occurrence.id, provider: 'provider', reasoning: null, runId: 'run-recovery', spaceId: null }) + createRunRepository(database).reconcileTerminal('run-recovery', 'completed', '2026-08-24T00:00:20.000Z', null) + return { automations, occurrence, conversations: createConversationRepository(database) } +} diff --git a/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts index e3991a8f..78c82601 100644 --- a/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts @@ -4,9 +4,13 @@ import type { AutomationClock } from '../AutomationScheduleEvaluator' import { afterEach, describe, expect, it, vi } from 'vitest' import { createAutomationRepositories } from '../../storage/automationRepository' +import { createAutomationTurnRepository } from '../../storage/automationTurnRepository' import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' +import { AutomationChangeCoordinator } from '../AutomationChangeCoordinator' import { AutomationScheduler } from '../AutomationScheduler' import { AutomationService } from '../AutomationService' +import { AutomationTurnService } from '../AutomationTurnService' const databases: DatabaseSync[] = [] @@ -16,6 +20,68 @@ afterEach(() => { }) describe('automationScheduler', () => { + it('does not become ready after the first scan fails and permits a later successful start', async () => { + const fixture = createFixture('2026-08-24T00:00:00.000Z') + const listDue = vi.spyOn(fixture.service, 'listDue').mockImplementationOnce(() => { + throw new Error('SQLite unavailable') + }) + const scheduler = fixture.scheduler(async () => {}) + const states: string[] = [] + scheduler.onDidChange(event => states.push(event.state)) + await expect(scheduler.start()).rejects.toThrow('SQLite unavailable') + expect(states).toEqual(['starting', 'degraded']) + await scheduler.start() + expect(scheduler.state).toBe('ready') + expect(listDue).toHaveBeenCalledTimes(2) + await scheduler.dispose() + await scheduler.settle() + expect(scheduler.state).toBe('drained') + }) + + it('consumes owner notifications without waking itself for claim, lease, schedule progress or completion', async () => { + const fixture = createFixture('2026-08-24T00:00:00.000Z') + fixture.service.create({ draft: onceDraft('2026-08-24T00:00:10.000Z'), requestId: 'no-loop' }) + fixture.clock.set('2026-08-24T00:00:20.000Z') + const scheduler = fixture.scheduler(async (occurrence) => { + fixture.service.finishQueued({ id: occurrence.id, leaseOwner: occurrence.leaseOwner, status: 'skipped', errorCode: 'AUTOMATION_DEFAULT_MODEL_UNAVAILABLE' }) + }) + const wake = vi.spyOn(scheduler, 'wake') + const listDue = vi.spyOn(fixture.service, 'listDue') + const changes = new AutomationChangeCoordinator({ service: fixture.service, wakeScheduler: () => scheduler.wake(), notify: () => {} }) + await scheduler.start() + await scheduler.settle() + expect(wake).not.toHaveBeenCalled() + expect(listDue).toHaveBeenCalledTimes(1) + expect(fixture.service.listHistory({}).items[0]?.status).toBe('skipped') + await scheduler.dispose() + await scheduler.settle() + await changes.dispose() + }) + + it('keeps an unresolved bound run recoverable and reports degraded until its real terminal state is reconciled', async () => { + const fixture = createFixture('2026-08-24T00:00:00.000Z') + fixture.service.create({ draft: onceDraft('2026-08-24T00:00:10.000Z'), requestId: 'bound-failure' }) + fixture.clock.set('2026-08-24T00:00:20.000Z') + const turns = new AutomationTurnService(createAutomationTurnRepository(fixture.database)) + const runs = createRunRepository(fixture.database) + const facts: string[] = [] + fixture.service.onDidCommit(event => facts.push(...event.facts.map(fact => fact.kind))) + const scheduler = fixture.scheduler(async (occurrence) => { + turns.bind({ boundAt: '2026-08-24T00:00:20.000Z', branchId: 'branch-bound', conversationId: 'conversation-bound', contextWindow: 100_000, maxTokens: 8_000, executionContext: null, leaseOwner: occurrence.leaseOwner!, messageId: 'message-bound', model: 'model', occurrenceId: occurrence.id, provider: 'provider', reasoning: null, runId: 'run-bound', spaceId: null }) + throw new Error('terminal storage unavailable') + }) + await scheduler.start() + await vi.waitFor(() => expect(scheduler.state).toBe('degraded')) + await scheduler.dispose() + await expect(scheduler.settle()).rejects.toThrow('Automation dispatch cleanup failed') + expect(fixture.service.listHistory({}).items[0]?.status).toBe('bound') + expect(runs.findById('run-bound')?.status).toBe('queued') + expect(facts).not.toContain('occurrence.finished') + runs.reconcileTerminal('run-bound', 'failed', '2026-08-24T00:00:30.000Z', 'RUNTIME_RESTARTED') + await scheduler.settle() + expect(scheduler.state).toBe('drained') + }) + it('polls every 30 seconds and stops claiming after disposal', async () => { vi.useFakeTimers() try { @@ -142,6 +208,7 @@ function createFixture(initialTime: string) { }) return { clock, + database, service, scheduler: (dispatch: (occurrence: AutomationOccurrenceRecord) => Promise) => ( new AutomationScheduler({ diff --git a/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts index 9de12f16..08022e93 100644 --- a/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' +import type { AutomationCommit } from '../AutomationEvents' import type { AutomationClock } from '../AutomationScheduleEvaluator' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -21,6 +22,58 @@ afterEach(() => { }) describe('automationService persistence', () => { + it('publishes one immutable transaction batch after durable state and never republishes a replay', () => { + const { service } = createFileService() + const commits: AutomationCommit[] = [] + const observed: Array<{ kind: string, status: string | undefined }> = [] + service.onDidCommit(() => { + throw new Error('optional observer failed') + }) + service.onDidCommit((event) => { + commits.push(event) + for (const fact of event.facts) { + if (fact.kind === 'definition.paused') + observed.push({ kind: fact.kind, status: service.get(fact.automationId)?.status }) + if (fact.kind === 'occurrence.finished') + observed.push({ kind: fact.kind, status: service.getOccurrence(fact.occurrenceId)?.status }) + } + }) + const create = { requestId: 'create-facts', draft: dailyDraft('Facts') } + const automation = service.create(create) + service.create(create) + const request = { automationId: automation.id, expectedRevision: 1, requestId: 'run-facts' } + const queued = service.runNow(request) + service.runNow(request) + const pause = { automationId: automation.id, expectedRevision: 1, requestId: 'pause-facts' } + service.pause(pause) + service.pause(pause) + expect(commits.map(event => event.facts.map(fact => fact.kind))).toEqual([ + ['definition.created'], + ['occurrence.queued'], + ['definition.paused', 'occurrence.finished'], + ]) + expect(observed).toEqual([{ kind: 'definition.paused', status: 'paused' }, { kind: 'occurrence.finished', status: 'cancelled' }]) + expect(commits[2]!.facts[1]).toMatchObject({ occurrenceId: queued.occurrence.id, status: 'cancelled' }) + expect(Object.isFrozen(commits[2]!.facts[1])).toBe(true) + expect(new Set(commits.map(event => event.operationId)).size).toBe(3) + expect(commits.map(event => event.revision)).toEqual([1, 2, 3]) + }) + + it('emits no definition or cancellation facts when their shared transaction rolls back', () => { + const { service } = createFileService() + const database = databases.at(-1)! + const automation = service.create({ requestId: 'create-rollback', draft: dailyDraft('Rollback') }) + const queued = service.runNow({ automationId: automation.id, expectedRevision: 1, requestId: 'run-rollback' }) + const commits: AutomationCommit[] = [] + service.onDidCommit(event => commits.push(event)) + database.exec(`CREATE TRIGGER reject_cancel BEFORE UPDATE OF status ON automation_occurrences + WHEN NEW.status = 'cancelled' BEGIN SELECT RAISE(ABORT, 'cancel failed'); END`) + expect(() => service.pause({ automationId: automation.id, expectedRevision: 1, requestId: 'pause-rollback' })).toThrow('cancel failed') + expect(service.get(automation.id)).toMatchObject({ revision: 1, status: 'active' }) + expect(service.getOccurrence(queued.occurrence.id)?.status).toBe('queued') + expect(commits).toEqual([]) + }) + it('returns the active occurrence when manual run is requested again', () => { const { service } = createFileService() const automation = service.create({ requestId: 'create-1', draft: dailyDraft('Active') }) diff --git a/apps/buddy/service/src/automations/__tests__/createAutomationTool.spec.ts b/apps/buddy/service/src/automations/__tests__/createAutomationTool.spec.ts index bb9030ba..391d286f 100644 --- a/apps/buddy/service/src/automations/__tests__/createAutomationTool.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/createAutomationTool.spec.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createAutomationRepositories } from '../../storage/automationRepository' import { openBuddyDatabase } from '../../storage/database' @@ -46,8 +46,9 @@ describe('createAutomationTool', () => { it('uses the product service for idempotent upsert and queues run_now immediately', async () => { const service = createService() - const onChanged = vi.fn() - const tool = createAutomationTool({ onChanged, service }) + const facts: string[] = [] + service.onDidCommit(event => facts.push(...event.facts.map(fact => fact.kind))) + const tool = createAutomationTool({ service }) const created = await execute(tool, { draft: dailyDraft('Daily review'), @@ -86,10 +87,10 @@ describe('createAutomationTool', () => { operation: 'run_now', runNowOutcome: 'started', }) - expect(onChanged).toHaveBeenCalledWith(automation.id) + expect(facts).toEqual(['definition.created', 'occurrence.queued']) const occurrenceId = service.listHistory({ automationId: automation.id }).items[0]!.id - const changeCount = onChanged.mock.calls.length + const changeCount = facts.length const repeated = await execute(tool, { automationId: automation.id, expectedRevision: automation.revision, @@ -100,7 +101,7 @@ describe('createAutomationTool', () => { occurrence: { id: occurrenceId }, runNowOutcome: 'already_running', }) - expect(onChanged).toHaveBeenCalledTimes(changeCount) + expect(facts).toHaveLength(changeCount) }) }) diff --git a/apps/buddy/service/src/automations/__tests__/registerAutomationRpc.spec.ts b/apps/buddy/service/src/automations/__tests__/registerAutomationRpc.spec.ts index bc6d867d..cc4b1d90 100644 --- a/apps/buddy/service/src/automations/__tests__/registerAutomationRpc.spec.ts +++ b/apps/buddy/service/src/automations/__tests__/registerAutomationRpc.spec.ts @@ -44,7 +44,6 @@ describe('registerAutomationRpc', () => { automations: service, conversationLifecycle: { delete: async () => false }, notifications: { removeAutomationRun: () => false }, - onChanged: automationId => changes.publish(automationId), }) const harness = createRpcHarness() const unregister = registerAutomationRpc({ @@ -140,10 +139,10 @@ describe('registerAutomationRpc', () => { created.id, created.id, ]) - expect(wakeCount).toBe(7) + expect(wakeCount).toBe(6) harness.notify('scheduler.wake', { reason: 'resume' }) harness.notify('scheduler.wake', { reason: 'invalid' }) - expect(wakeCount).toBe(8) + expect(wakeCount).toBe(7) unregister() expect(harness.notificationListenerCount()).toBe(0) diff --git a/apps/buddy/service/src/automations/createAutomationTool.ts b/apps/buddy/service/src/automations/createAutomationTool.ts index 4a2e909a..e5e8ee06 100644 --- a/apps/buddy/service/src/automations/createAutomationTool.ts +++ b/apps/buddy/service/src/automations/createAutomationTool.ts @@ -158,7 +158,6 @@ export const automationToolParameters = Type.Union([ ], { type: 'object' }) export interface CreateAutomationToolOptions { - onChanged?: (automationId: string) => void service: AutomationService } @@ -192,9 +191,6 @@ export function createAutomationTool(options: CreateAutomationToolOptions) { return failure(readOperation(input), 'VALIDATION_FAILED') try { const result = executeAutomationOperation(options.service, parsed.data) - const automationId = result.automation?.id ?? result.occurrence?.automationId - if (automationId && isMutation(parsed.data.operation) && result.changed !== false) - options.onChanged?.(automationId) return { content: [{ type: 'text' as const, text: JSON.stringify(result.value) }], details: result.details, @@ -395,10 +391,6 @@ function readOperation(input: unknown): AutomationToolDetails['operation'] { : 'invalid' } -function isMutation(operation: AutomationToolOperation): boolean { - return operation !== 'list' && operation !== 'get' -} - export function classifyAutomationToolCall( service: AutomationService, event: ToolCallEvent, diff --git a/apps/buddy/service/src/automations/registerAutomationRpc.ts b/apps/buddy/service/src/automations/registerAutomationRpc.ts index 8dba7a2b..f5aa5da0 100644 --- a/apps/buddy/service/src/automations/registerAutomationRpc.ts +++ b/apps/buddy/service/src/automations/registerAutomationRpc.ts @@ -64,43 +64,36 @@ export function registerAutomationRpc(options: RegisterAutomationRpcOptions): () const automation = options.service.create( params, ) - options.changes.publish(automation.id) return automation })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.update, (params) => { const automation = options.service.update( params, ) - options.changes.publish(automation.id) return automation })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.pause, (params) => { const automation = options.service.pause( params, ) - options.changes.publish(automation.id) return automation })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.resume, (params) => { const automation = options.service.resume( params, ) - options.changes.publish(automation.id) return automation })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.delete, (params) => { const automation = options.service.delete( params, ) - options.changes.publish(automation.id) return automation })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.runNow, (params) => { const result = options.service.runNow( params, ) - if (result.outcome === 'started') - options.changes.publish(result.occurrence.automationId) return toAutomationRunNowResult(result) })) disposers.push(registerRuntimeRequest(options.rpc, automationsRpc.listOccurrences, (params) => { diff --git a/apps/buddy/service/src/browser/BrowserCapabilityService.ts b/apps/buddy/service/src/browser/BrowserCapabilityService.ts index 21423639..2e599cd7 100644 --- a/apps/buddy/service/src/browser/BrowserCapabilityService.ts +++ b/apps/buddy/service/src/browser/BrowserCapabilityService.ts @@ -25,8 +25,10 @@ import type { OpenBrowserLocalInput, OpenBrowserUrlInput, } from './BrowserHostClient' -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { getBrowserActionRef } from '../../../shared/browser' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { classifyBrowserAction } from '../approvals/browser/classifyBrowserAction' export type BrowserCapabilityOpenTarget = { @@ -92,7 +94,31 @@ interface BrowserPolicyObservation { url: BrowserObservation['url'] } +export type BrowserCapabilityChange = { + readonly revision: number + readonly kind: 'binding' | 'policy' + readonly sessionId: string | null + readonly pageId: string | null + readonly observationId: string | null + readonly documentRevision: number | null + readonly count: number +} | { + readonly revision: number + readonly kind: 'action' | 'control' + readonly operationId: string + readonly phase: 'dispatched' | 'response-received' | 'response-unknown' | 'caller-settled' | 'acquired' | 'released' | 'release-unknown' + readonly cancelled: boolean + readonly accepted?: boolean + readonly controlEpoch?: number +} + export class BrowserCapabilityService { + readonly #changes = new Emitter(() => console.error('BROWSER_CAPABILITY_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #shutdown = new AbortController() + readonly #pending = new Set>() + #revision = 0 + #disposing: Promise | undefined readonly #conversationId: string readonly #getGrants: () => readonly DirectoryGrant[] readonly #host: BrowserCapabilityHost @@ -105,8 +131,16 @@ export class BrowserCapabilityService { this.#host = options.host } - async open(target: BrowserCapabilityOpenTarget, grants = this.#getGrants()): Promise { - this.#policyObservation = null + get snapshot() { return copyEventSnapshot({ revision: this.#revision, stopping: this.#shutdown.signal.aborted, pending: this.#pending.size, ...this.#binding() }) } + + open(target: BrowserCapabilityOpenTarget, grants = this.#getGrants()): Promise { + const captured = copyEventSnapshot(target) + const capturedGrants = grants.map(cloneGrant) + return this.#track(() => this.#open(captured, capturedGrants)) + } + + async #open(target: BrowserCapabilityOpenTarget, grants: readonly DirectoryGrant[]): Promise { + this.#setPolicy(null) const result = target.kind === 'url' ? await this.#host.openUrl({ conversationId: this.#conversationId, @@ -124,18 +158,21 @@ export class BrowserCapabilityService { return result } if (result.state.conversationId !== this.#conversationId) { - this.#sessionId = null + this.#setBinding(null) return sessionNotFound() } - this.#sessionId = result.state.sessionId + this.#setBinding(result.state.sessionId) return result } - async observe( - input: BrowserCapabilityObserveInput = {}, - ): Promise { - this.#policyObservation = null - const state = await this.getState() + observe(input: BrowserCapabilityObserveInput = {}): Promise { + const captured = { ...input } + return this.#track(() => this.#observe(captured)) + } + + async #observe(input: BrowserCapabilityObserveInput): Promise { + this.#setPolicy(null) + const state = await this.#getState() if (!state.ok) return state const request: BrowserObserveParams = { @@ -149,12 +186,12 @@ export class BrowserCapabilityService { return result } if (result.observation.sessionId !== state.state.sessionId) { - this.#sessionId = null + this.#setBinding(null) return sessionNotFound() } if (result.observation.pageId !== state.state.pageId) return targetStale() - this.#policyObservation = createPolicyObservation(result.observation) + this.#setPolicy(createPolicyObservation(result.observation)) return result } @@ -203,10 +240,13 @@ export class BrowserCapabilityService { return classification } - async validateActionApproval( - input: BrowserCapabilityActInput, - expectedReview: BrowserApprovalReviewInput, - ): Promise { + validateActionApproval(input: BrowserCapabilityActInput, expectedReview: BrowserApprovalReviewInput): Promise { + const captured = structuredClone(input) + const review = structuredClone(expectedReview) + return this.#track(() => this.#validateActionApproval(captured, review)) + } + + async #validateActionApproval(input: BrowserCapabilityActInput, expectedReview: BrowserApprovalReviewInput): Promise { const classification = this.classifyAction(input) if ( 'blocked' in classification @@ -216,29 +256,38 @@ export class BrowserCapabilityService { || classification.approvalReview.pageId !== expectedReview.pageId || classification.approvalReview.documentRevision !== expectedReview.documentRevision ) { - this.#policyObservation = null + this.#setPolicy(null) return staleApprovalValidation() } const sessionId = this.#sessionId if (!sessionId || sessionId !== expectedReview.sessionId) { - this.#policyObservation = null + this.#setPolicy(null) return staleApprovalValidation() } const result = await this.#host.validateAction({ ...input, sessionId }) if (!result.ok) { - this.#policyObservation = null + this.#setPolicy(null) this.#clearUnavailableBinding(result.error.code) return { blocked: true, reason: result.error.code } } return null } - async act( - input: BrowserCapabilityActInput, - signal = new AbortController().signal, - ): Promise { + act(input: BrowserCapabilityActInput, parent = new AbortController().signal): Promise { + const captured = structuredClone(input) + const signal = AbortSignal.any([parent, this.#shutdown.signal]) + const operationId = randomUUID() + return this.#track(async () => { + try { + return await this.#act(captured, signal, operationId) + } + finally { this.#publish({ kind: 'action', operationId, phase: 'caller-settled', cancelled: signal.aborted }) } + }) + } + + async #act(input: BrowserCapabilityActInput, signal: AbortSignal, operationId: string): Promise { signal.throwIfAborted() - const state = await this.getState() + const state = await this.#getState() if (!state.ok) return state if (state.state.pageId !== input.pageId) @@ -253,36 +302,45 @@ export class BrowserCapabilityService { return acquired } const { lease } = acquired + this.#publish({ kind: 'control', operationId, phase: 'acquired', controlEpoch: lease.controlEpoch, cancelled: signal.aborted }) const releaseInput: BrowserReleaseControlParams = { controlEpoch: lease.controlEpoch, pageId: lease.pageId, sessionId: lease.sessionId, } if (lease.sessionId !== state.state.sessionId) { - await this.#releaseControl(releaseInput) - this.#sessionId = null + await this.#releaseControl(releaseInput, operationId, signal) + this.#setBinding(null) return sessionNotFound() } if (lease.pageId !== input.pageId) { - await this.#releaseControl(releaseInput) + await this.#releaseControl(releaseInput, operationId, signal) return targetStale() } let releasePromise: Promise | null = null const release = () => { - releasePromise ??= this.#releaseControl(releaseInput) + releasePromise ??= this.#releaseControl(releaseInput, operationId, signal) return releasePromise } const onAbort = () => void release() signal.addEventListener('abort', onAbort, { once: true }) try { signal.throwIfAborted() - this.#policyObservation = null - const result = await waitForAbort(this.#host.act({ - ...input, - controlEpoch: lease.controlEpoch, - sessionId: lease.sessionId, - }), signal) + this.#setPolicy(null) + this.#publish({ kind: 'action', operationId, phase: 'dispatched', cancelled: signal.aborted }) + const response = this.#track(async () => { + try { + const result = await this.#host.act({ ...input, controlEpoch: lease.controlEpoch, sessionId: lease.sessionId }) + this.#publish({ kind: 'action', operationId, phase: 'response-received', accepted: result.ok, cancelled: signal.aborted }) + return result + } + catch (error) { + this.#publish({ kind: 'action', operationId, phase: 'response-unknown', cancelled: signal.aborted }) + throw error + } + }) + const result = await waitForAbort(response, signal) if (!result.ok) { this.#clearUnavailableBinding(result.error.code) return result @@ -292,7 +350,7 @@ export class BrowserCapabilityService { || result.state.sessionId !== lease.sessionId || result.observation.sessionId !== lease.sessionId ) { - this.#sessionId = null + this.#setBinding(null) return sessionNotFound() } if ( @@ -301,7 +359,7 @@ export class BrowserCapabilityService { ) { return targetStale() } - this.#policyObservation = createPolicyObservation(result.observation) + this.#setPolicy(createPolicyObservation(result.observation)) return result } finally { @@ -310,7 +368,9 @@ export class BrowserCapabilityService { } } - async getState(): Promise { + getState(): Promise { return this.#track(() => this.#getState()) } + + async #getState(): Promise { const sessionId = this.#sessionId if (!sessionId) return sessionNotFound() @@ -323,38 +383,83 @@ export class BrowserCapabilityService { result.state.conversationId !== this.#conversationId || result.state.sessionId !== sessionId ) { - this.#sessionId = null + this.#setBinding(null) return sessionNotFound() } return result } - async close(): Promise { + close(): Promise { return this.#track(() => this.#close()) } + + async #close(): Promise { const sessionId = this.#sessionId if (!sessionId) return sessionNotFound() const result = await this.#host.close(sessionId) if (result.ok || isUnavailable(result.error.code)) { - this.#sessionId = null - this.#policyObservation = null + this.#setBinding(null) + this.#setPolicy(null) } return result } #clearUnavailableBinding(code: BrowserErrorCode): void { if (isUnavailable(code)) { - this.#sessionId = null - this.#policyObservation = null + this.#setBinding(null) + this.#setPolicy(null) } } - async #releaseControl(input: BrowserReleaseControlParams): Promise { + async #releaseControl(input: BrowserReleaseControlParams, operationId: string, signal: AbortSignal): Promise { try { const result = await this.#host.releaseControl(input) + this.#publish({ kind: 'control', operationId, phase: result.ok ? 'released' : 'release-unknown', controlEpoch: input.controlEpoch, cancelled: signal.aborted }) if (!result.ok) this.#clearUnavailableBinding(result.error.code) } - catch {} + catch { this.#publish({ kind: 'control', operationId, phase: 'release-unknown', controlEpoch: input.controlEpoch, cancelled: signal.aborted }) } + } + + dispose(): Promise { + this.#disposing ??= Promise.resolve().then(async () => { + await Promise.allSettled([...this.#pending]) + this.#setPolicy(null) + this.#setBinding(null) + this.#changes.dispose() + }) + this.#shutdown.abort() + return this.#disposing + } + + #track(operation: () => Promise): Promise { + if (this.#shutdown.signal.aborted) + return Promise.reject(this.#shutdown.signal.reason) + const pending = Promise.resolve().then(operation) + this.#pending.add(pending) + void pending.finally(() => this.#pending.delete(pending)).catch(() => {}) + return pending + } + + #binding() { + return { sessionId: this.#sessionId, pageId: this.#policyObservation?.pageId ?? null, observationId: this.#policyObservation?.observationId ?? null, documentRevision: this.#policyObservation?.documentRevision ?? null, count: this.#policyObservation?.elements.size ?? 0 } + } + + #setBinding(sessionId: string | null): void { + if (this.#sessionId === sessionId) + return + this.#sessionId = sessionId + this.#changes.fire(copyEventSnapshot({ kind: 'binding', revision: ++this.#revision, ...this.#binding() })) + } + + #setPolicy(observation: BrowserPolicyObservation | null): void { + if (this.#policyObservation === observation) + return + this.#policyObservation = observation + this.#changes.fire(copyEventSnapshot({ kind: 'policy', revision: ++this.#revision, ...this.#binding() })) + } + + #publish(change: Omit, 'revision'>): void { + this.#changes.fire(copyEventSnapshot({ ...change, revision: ++this.#revision })) } } @@ -396,7 +501,8 @@ function staleClassification(): BrowserCapabilityActionClassificationResult { function createPolicyObservation( observation: BrowserObservation, ): BrowserPolicyObservation { - const elements = new Map(observation.elements.map(element => [ + const captured = copyEventSnapshot(observation) + const elements = new Map(captured.elements.map(element => [ element.ref, { ...element, @@ -405,15 +511,15 @@ function createPolicyObservation( }, ])) return { - documentRevision: observation.documentRevision, + documentRevision: captured.documentRevision, elements, - observationContainsHumanInput: observation.elements.some( + observationContainsHumanInput: captured.elements.some( element => element.inputMode === 'human', ), - observationId: observation.observationId, - pageId: observation.pageId, - sessionId: observation.sessionId, - url: observation.url, + observationId: captured.observationId, + pageId: captured.pageId, + sessionId: captured.sessionId, + url: captured.url, } } diff --git a/apps/buddy/service/src/browser/__tests__/BrowserCapabilityService.spec.ts b/apps/buddy/service/src/browser/__tests__/BrowserCapabilityService.spec.ts index 53c3f050..28d8401d 100644 --- a/apps/buddy/service/src/browser/__tests__/BrowserCapabilityService.spec.ts +++ b/apps/buddy/service/src/browser/__tests__/BrowserCapabilityService.spec.ts @@ -418,6 +418,11 @@ describe('browserCapabilityService', () => { host.act.mockReturnValue(action.promise) host.releaseControl.mockResolvedValue({ ok: true }) const service = createService(host) + const phases: string[] = [] + service.onDidChange((change) => { + if ('phase' in change) + phases.push(change.phase) + }) await service.open({ kind: 'url', url: READY_STATE.url }) const controller = new AbortController() const acting = service.act(ACTION_INPUT, controller.signal) @@ -431,7 +436,18 @@ describe('browserCapabilityService', () => { pageId: PAGE_ID, sessionId: SESSION_ID, }) + let disposed = false + const stopping = service.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + expect(phases).toContain('caller-settled') + expect(phases).not.toContain('response-unknown') action.reject(new Error('cancelled action finished later')) + await stopping + expect(phases.at(-1)).toBe('response-unknown') + expect(service.snapshot.pending).toBe(0) }) it('fails closed and releases a binding when Host state crosses conversation or session', async () => { diff --git a/apps/buddy/service/src/browser/browserExtension.ts b/apps/buddy/service/src/browser/browserExtension.ts index 483ecf35..e7792236 100644 --- a/apps/buddy/service/src/browser/browserExtension.ts +++ b/apps/buddy/service/src/browser/browserExtension.ts @@ -7,6 +7,7 @@ import type { BrowserStateSnapshot, BrowserWaitOutcome, } from '../../../shared/browser' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { BuddyCapability } from '../agent/extensions/BuddyCapability' import type { BuddyInProcessExtension } from '../agent/extensions/BuddyInProcessExtension' import type { BrowserCapabilityServiceOptions } from './BrowserCapabilityService' @@ -31,6 +32,7 @@ import { isBrowserOpenToolInput, isBrowserSnapshotToolInput, } from './browserToolContract' +import { observeBrowserCapabilityDiagnostics } from './observeBrowserCapabilityDiagnostics' type BrowserExtensionService = Pick @@ -62,9 +64,16 @@ export interface CreateBrowserExtensionOptions { onOpened?: () => Promise } -export function createBrowserCapability(options: BrowserCapabilityServiceOptions & Pick): BuddyCapability { +export function createBrowserCapability(options: BrowserCapabilityServiceOptions & Pick & { report?: ApplicationDiagnosticReporter }): BuddyCapability { const service = new BrowserCapabilityService(options) + const diagnostics = options.report ? observeBrowserCapabilityDiagnostics(service, options.report) : undefined return { + async dispose() { + try { + await service.dispose() + } + finally { diagnostics?.dispose() } + }, extension: createBrowserExtension({ service, getExecutionGrants: options.getExecutionGrants, onOpened: options.onOpened }), classify: event => classifyBrowserTool(event, service), disclosure: { diff --git a/apps/buddy/service/src/browser/observeBrowserCapabilityDiagnostics.ts b/apps/buddy/service/src/browser/observeBrowserCapabilityDiagnostics.ts new file mode 100644 index 00000000..84dc4e20 --- /dev/null +++ b/apps/buddy/service/src/browser/observeBrowserCapabilityDiagnostics.ts @@ -0,0 +1,18 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { BrowserCapabilityService } from './BrowserCapabilityService' + +export function observeBrowserCapabilityDiagnostics(service: BrowserCapabilityService, report: ApplicationDiagnosticReporter) { + return service.onDidChange((change) => { + if ('phase' in change) { + report({ + event: `browser.client.${change.kind}.${change.phase.replaceAll('-', '_')}${change.cancelled ? '_after_cancel' : ''}`, + component: 'runtime.browser', + level: change.phase.endsWith('unknown') || change.accepted === false ? 'warn' : 'info', + operationId: change.operationId, + revision: change.revision, + }) + return + } + report({ event: `browser.client.${change.kind}.changed`, component: 'runtime.browser', level: 'info', revision: change.revision, count: change.count }) + }) +} diff --git a/apps/buddy/service/src/changes/ChangeCaptureService.ts b/apps/buddy/service/src/changes/ChangeCaptureService.ts index bb8d5181..e5b1b735 100644 --- a/apps/buddy/service/src/changes/ChangeCaptureService.ts +++ b/apps/buddy/service/src/changes/ChangeCaptureService.ts @@ -10,6 +10,8 @@ import type { WorkspaceSnapshot, WorkspaceSnapshotState } from './workspaceSnaps import { randomUUID } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { isAbsolute, resolve } from 'node:path' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { captureChangeFile } from './captureChangeFile' import { displayGrantedPath } from './changeFileContent' @@ -58,17 +60,124 @@ interface PendingWorkspaceCapture { snapshot: WorkspaceSnapshot } +export interface ChangeCaptureEvent { + readonly sourceId: string + readonly revision: number + readonly operationId: string + readonly conversationId?: string + readonly runId: string + readonly kind: 'set-created' | 'capture-committed' | 'coverage-changed' | 'count-changed' | 'finalized' | 'operation-failed' | 'coverage-unconfirmed' + readonly captureId?: string + readonly toolCallId?: string + readonly phase?: 'before' | 'after' | 'workspace' + readonly coverage?: 'complete' | 'partial' + readonly count?: number + readonly errorCode?: 'CHANGE_CAPTURE_FAILED' | 'CHANGE_COVERAGE_UNCONFIRMED' +} + +interface WorkspaceToolInput { + conversationId: string + cwd: string + grants: readonly DirectoryGrant[] + runId: string + toolCallId: string +} + export class ChangeCaptureService { readonly #paths: BuddyDataPaths readonly #repository: ChangeSetRepository + readonly #changes: Emitter + readonly onDidChange + readonly #mutations = new Map>() + readonly #contexts = new Map() + readonly #sourceId = randomUUID() + #revision = 0 + #disposed = false readonly #workspaceCaptures = new Map() - constructor(options: { paths: BuddyDataPaths, repository: ChangeSetRepository }) { + constructor(options: { paths: BuddyDataPaths, repository: ChangeSetRepository, onListenerError?: (error: unknown) => void }) { this.#paths = options.paths this.#repository = options.repository + this.#changes = new Emitter(options.onListenerError ?? (() => console.error('CHANGE_CAPTURE_OBSERVER_FAILED'))) + this.onDidChange = this.#changes.event + } + + beginFileTool(input: FileToolInput & { arguments: unknown }): Promise { + const request = copyEventSnapshot(input) + return this.#operate(request.runId, 'before', () => this.#beginFileTool(request), request.conversationId) + } + + finishFileTool(input: FileToolInput & { isError: boolean }): Promise { + const request = copyEventSnapshot(input) + return this.#operate(request.runId, 'after', () => this.#finishFileTool(request), request.conversationId) + } + + beginWorkspaceTool(input: WorkspaceToolInput): Promise { + const request = copyEventSnapshot(input) + return this.#operate(request.runId, 'before', () => this.#beginWorkspaceTool(request), request.conversationId) + } + + finishWorkspaceTool(input: WorkspaceToolInput & { isError: boolean, toolName: string }): Promise<{ complete: boolean }> { + const request = copyEventSnapshot(input) + return this.#operate(request.runId, 'after', () => this.#finishWorkspaceTool(request), request.conversationId) + } + + markPartial(input: { conversationId: string, runId: string }): Promise { + const request = copyEventSnapshot(input) + return this.#operate(request.runId, 'coverage', () => this.#markPartial(request), request.conversationId) } - async beginFileTool(input: FileToolInput & { arguments: unknown }): Promise { + markInterrupted(runId: string): Promise { + return this.#operate(runId, 'finalize', () => this.#markInterrupted(runId)) + } + + finalizeRun(runId: string): Promise { + return this.#operate(runId, 'finalize', () => this.#finalizeRun(runId)) + } + + async dispose(): Promise { + this.#disposed = true + await Promise.allSettled([...this.#mutations.values()]) + this.#workspaceCaptures.clear() + this.#changes.dispose() + } + + #operate(runId: string, phase: 'before' | 'after' | 'coverage' | 'finalize', operation: () => Promise, conversationId?: string): Promise { + if (this.#disposed) + return Promise.reject(new Error('CHANGE_CAPTURE_STOPPED')) + const previous = this.#mutations.get(runId) ?? Promise.resolve() + const pending = previous.catch(() => {}).then(async () => { + this.#contexts.set(runId, { operationId: randomUUID(), conversationId }) + try { + const current = this.#repository.findSetById(runId) + if (current) + this.#contexts.get(runId)!.conversationId = current.conversationId + if ((phase === 'before' || phase === 'after') && current?.status === 'completed') + throw new ChangeCaptureError('CHANGE_SET_FINALIZED') + return await operation() + } + catch (error) { + const unconfirmed = phase === 'coverage' || (error instanceof ChangeCaptureError && error.code === 'CHANGE_COVERAGE_UNCONFIRMED') + this.#publish(runId, { kind: unconfirmed ? 'coverage-unconfirmed' : 'operation-failed', errorCode: unconfirmed ? 'CHANGE_COVERAGE_UNCONFIRMED' : 'CHANGE_CAPTURE_FAILED' }) + throw error + } + finally { + this.#contexts.delete(runId) + } + }).finally(() => { + if (this.#mutations.get(runId) === pending) + this.#mutations.delete(runId) + }) + this.#mutations.set(runId, pending) + return pending + } + + #publish(runId: string, details: Omit): void { + const context = this.#contexts.get(runId)! + this.#changes.fire(copyEventSnapshot({ ...details, ...context, runId, sourceId: this.#sourceId, revision: ++this.#revision })) + } + + async #beginFileTool(input: FileToolInput & { arguments: unknown }): Promise { const requestedPath = readToolPath(input.arguments) const absolutePath = isAbsolute(requestedPath) ? requestedPath @@ -106,9 +215,10 @@ export class ChangeCaptureService { toolName: input.toolName, toolReportedError: null, }) + this.#publish(input.runId, { kind: 'capture-committed', phase: 'before', captureId, toolCallId: input.toolCallId }) } - async finishFileTool( + async #finishFileTool( input: FileToolInput & { isError: boolean }, ): Promise { const capture = this.#repository.findCaptureByToolCallId(input.toolCallId) @@ -128,11 +238,12 @@ export class ChangeCaptureService { side: 'after', }) const now = new Date().toISOString() - this.#repository.completeCapture(capture.id, after, input.isError, now) + if (this.#repository.completeCapture(capture.id, after, input.isError, now)) + this.#publish(input.runId, { kind: 'capture-committed', phase: 'after', captureId: capture.id, toolCallId: input.toolCallId }) this.#refreshFileCount(input.runId, now) } - async beginWorkspaceTool(input: { + async #beginWorkspaceTool(input: { conversationId: string cwd: string grants: readonly DirectoryGrant[] @@ -140,15 +251,17 @@ export class ChangeCaptureService { toolCallId: string }): Promise { const grants = input.grants.map(grant => ({ ...grant })) + const snapshot = await captureWorkspaceSnapshot(grants, input.cwd) + this.#ensureSet(input.runId, input.conversationId, new Date().toISOString()) this.#workspaceCaptures.set(input.toolCallId, { conversationId: input.conversationId, grants, runId: input.runId, - snapshot: await captureWorkspaceSnapshot(grants, input.cwd), + snapshot, }) } - async finishWorkspaceTool(input: { + async #finishWorkspaceTool(input: { conversationId: string cwd: string grants: readonly DirectoryGrant[] @@ -181,33 +294,40 @@ export class ChangeCaptureService { return { complete: before.snapshot.complete && after.complete } } - async markPartial(input: { conversationId: string, runId: string }): Promise { + async #markPartial(input: { conversationId: string, runId: string }): Promise { const now = new Date().toISOString() this.#ensureSet(input.runId, input.conversationId, now) - this.#repository.markPartial(input.runId, now) + if (this.#setPartial(input.runId, now)) + this.#publish(input.runId, { kind: 'coverage-changed', coverage: 'partial' }) } - async markInterrupted(runId: string): Promise { + async #markInterrupted(runId: string): Promise { this.#discardWorkspaceCaptures(runId) const changeSet = this.#repository.findSetById(runId) if (!changeSet) return const now = new Date().toISOString() - this.#repository.markPartial(runId, now) + if (this.#setPartial(runId, now)) + this.#publish(runId, { kind: 'coverage-changed', coverage: 'partial' }) const captures = this.#repository.listCaptures(runId) - this.#repository.finalizeSet(runId, aggregateCaptures(captures).length, now) + const count = aggregateCaptures(captures).length + if (this.#repository.finalizeSet(runId, count, now)) + this.#publish(runId, { kind: 'finalized', count }) } - async finalizeRun(runId: string): Promise { + async #finalizeRun(runId: string): Promise { + const pendingWorkspace = [...this.#workspaceCaptures.values()].some(capture => capture.runId === runId) this.#discardWorkspaceCaptures(runId) const changeSet = this.#repository.findSetById(runId) if (!changeSet) return const captures = this.#repository.listCaptures(runId) - if (captures.some(capture => capture.status === 'pending')) - this.#repository.markPartial(runId, new Date().toISOString()) + if ((pendingWorkspace || captures.some(capture => capture.status === 'pending')) && this.#setPartial(runId, new Date().toISOString())) + this.#publish(runId, { kind: 'coverage-changed', coverage: 'partial' }) const now = new Date().toISOString() - this.#repository.finalizeSet(runId, aggregateCaptures(captures).length, now) + const count = aggregateCaptures(captures).length + if (this.#repository.finalizeSet(runId, count, now)) + this.#publish(runId, { kind: 'finalized', count }) } listSummariesForRuns(runIds: readonly string[]): LocalChangeSetSummary[] { @@ -243,7 +363,7 @@ export class ChangeCaptureService { } #ensureSet(runId: string, conversationId: string, now: string): ChangeSetRecord { - return this.#repository.ensureSet({ + const result = this.#repository.ensureSet({ conversationId, coverage: 'complete', createdAt: now, @@ -253,11 +373,24 @@ export class ChangeCaptureService { status: 'capturing', updatedAt: now, }) + if (result.created) + this.#publish(runId, { kind: 'set-created', coverage: result.record.coverage, count: result.record.fileCount }) + return result.record + } + + #setPartial(runId: string, now: string): boolean { + try { + return this.#repository.markPartial(runId, now) + } + catch { + throw new ChangeCaptureError('CHANGE_COVERAGE_UNCONFIRMED') + } } #refreshFileCount(changeSetId: string, now: string): void { const count = aggregateCaptures(this.#repository.listCaptures(changeSetId)).length - this.#repository.updateFileCount(changeSetId, count, now) + if (this.#repository.updateFileCount(changeSetId, count, now)) + this.#publish(changeSetId, { kind: 'count-changed', count }) } async #persistWorkspaceChanges(input: { @@ -313,6 +446,7 @@ export class ChangeCaptureService { toolName: input.toolName, toolReportedError: input.isError, }) + this.#publish(input.runId, { kind: 'capture-committed', phase: 'workspace', captureId, toolCallId: input.toolCallId }) } this.#refreshFileCount(input.runId, now) } diff --git a/apps/buddy/service/src/changes/__tests__/ChangeCaptureService.spec.ts b/apps/buddy/service/src/changes/__tests__/ChangeCaptureService.spec.ts index 22c58868..7c7dce88 100644 --- a/apps/buddy/service/src/changes/__tests__/ChangeCaptureService.spec.ts +++ b/apps/buddy/service/src/changes/__tests__/ChangeCaptureService.spec.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' +import type { ChangeCaptureEvent } from '../ChangeCaptureService' import { mkdir, mkdtemp, open, rename, rm, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -19,6 +20,83 @@ afterEach(async () => { }) describe('changeCaptureService', () => { + it('reports a committed capture even when count and partial coverage cannot be persisted', async () => { + const root = await mkdtemp(join(tmpdir(), 'buddy-change-receipts-')) + directories.push(root) + const workspace = join(root, 'workspace') + await mkdir(workspace) + const path = join(workspace, 'private.txt') + await writeFile(path, 'private before') + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + seedRun(database) + const repository = createChangeSetRepository(database) + let failing = true + const service = new ChangeCaptureService({ paths: new BuddyDataPaths(root), onListenerError: () => {}, repository: { ...repository, updateFileCount(...args) { + if (failing) + throw new Error('count failed') + + return repository.updateFileCount(...args) + }, markPartial(...args) { + if (failing) + throw new Error('coverage failed') + + return repository.markPartial(...args) + } } }) + const events: ChangeCaptureEvent[] = [] + service.onDidChange(() => { + throw new Error('observer failed') + }) + service.onDidChange(event => events.push(event)) + const input = { conversationId: 'conversation-1', runId: 'run-1', toolCallId: 'tool-1', toolName: 'edit' as const, cwd: workspace, grants: [{ root: workspace, canonicalRoot: workspace, grantId: 'grant-1', kind: 'workspace' as const }] } + await service.beginFileTool({ ...input, arguments: { path } }) + await writeFile(path, 'private after') + await expect(service.finishFileTool({ ...input, isError: false })).rejects.toThrow('count failed') + expect(repository.listCaptures(input.runId)[0]?.status).toBe('completed') + await expect(service.markPartial(input)).rejects.toMatchObject({ code: 'CHANGE_COVERAGE_UNCONFIRMED' }) + expect(repository.findSetById(input.runId)?.coverage).toBe('complete') + expect(events.map(event => event.kind)).toEqual(['set-created', 'capture-committed', 'capture-committed', 'operation-failed', 'coverage-unconfirmed']) + failing = false + await service.finishFileTool({ ...input, isError: false }) + await service.markPartial(input) + await service.markPartial(input) + await service.finalizeRun(input.runId) + await service.finalizeRun(input.runId) + expect(events.filter(event => event.phase === 'after')).toHaveLength(1) + expect(events.filter(event => event.kind === 'coverage-changed')).toHaveLength(1) + expect(events.filter(event => event.kind === 'finalized')).toHaveLength(1) + expect(await service.getVisibleDetail(input.runId)).toMatchObject({ coverage: 'partial', fileCount: 1, status: 'completed', files: [{ beforeText: 'private before', afterText: 'private after' }] }) + expect(JSON.stringify(events)).not.toContain(workspace) + expect(JSON.stringify(events)).not.toContain('private') + await service.dispose() + }) + + it('serializes an accepted workspace snapshot before finalization and drains both at shutdown', async () => { + const root = await mkdtemp(join(tmpdir(), 'buddy-change-drain-')) + directories.push(root) + const workspace = join(root, 'workspace') + await mkdir(workspace) + await writeFile(join(workspace, 'private.txt'), 'private') + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + seedRun(database) + const repository = createChangeSetRepository(database) + const service = new ChangeCaptureService({ paths: new BuddyDataPaths(root), repository }) + const events: ChangeCaptureEvent[] = [] + service.onDidChange(event => events.push(event)) + const input = { conversationId: 'conversation-1', runId: 'run-1', toolCallId: 'shell-1', cwd: workspace, grants: [{ root: workspace, canonicalRoot: workspace, grantId: 'grant-1', kind: 'workspace' as const }] } + const before = service.beginWorkspaceTool(input) + input.grants.length = 0 + const finalized = service.finalizeRun(input.runId) + const disposed = service.dispose() + await Promise.all([before, finalized, disposed]) + expect(repository.findSetById(input.runId)).toMatchObject({ coverage: 'partial', status: 'completed' }) + expect(events.map(event => event.kind)).toEqual(['set-created', 'coverage-changed', 'finalized']) + expect(events.map(event => event.revision)).toEqual([1, 2, 3]) + expect(events.every(event => Object.isFrozen(event))).toBe(true) + await expect(service.beginWorkspaceTool(input)).rejects.toThrow('CHANGE_CAPTURE_STOPPED') + }) + it('finishes the original capture scope after temporary permissions are revoked', async () => { const root = await mkdtemp(join(tmpdir(), 'buddy-expired-change-')) directories.push(root) diff --git a/apps/buddy/service/src/changes/changeSetRepository.ts b/apps/buddy/service/src/changes/changeSetRepository.ts index ebd25c72..5918b902 100644 --- a/apps/buddy/service/src/changes/changeSetRepository.ts +++ b/apps/buddy/service/src/changes/changeSetRepository.ts @@ -46,17 +46,17 @@ export interface ChangeSetRepository { after: CapturedFileStateRecord, toolReportedError: boolean, completedAt: string, - ) => void + ) => boolean createCapture: (record: FileChangeCaptureRecord) => void - ensureSet: (record: ChangeSetRecord) => ChangeSetRecord + ensureSet: (record: ChangeSetRecord) => { created: boolean, record: ChangeSetRecord } findCaptureByToolCallId: (toolCallId: string) => FileChangeCaptureRecord | null findSetById: (id: string) => ChangeSetRecord | null findVisibleSetById: (id: string) => ChangeSetRecord | null - finalizeSet: (id: string, fileCount: number, updatedAt: string) => void + finalizeSet: (id: string, fileCount: number, updatedAt: string) => boolean listCaptures: (changeSetId: string) => FileChangeCaptureRecord[] listSetsForRuns: (runIds: readonly string[]) => ChangeSetRecord[] - markPartial: (id: string, updatedAt: string) => void - updateFileCount: (id: string, fileCount: number, updatedAt: string) => void + markPartial: (id: string, updatedAt: string) => boolean + updateFileCount: (id: string, fileCount: number, updatedAt: string) => boolean } interface ChangeSetRow { @@ -150,20 +150,20 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo const markPartial = database.prepare(` UPDATE run_change_sets SET coverage = 'partial', updated_at = ? - WHERE id = ? + WHERE id = ? AND coverage <> 'partial' `) const updateFileCount = database.prepare(` - UPDATE run_change_sets SET file_count = ?, updated_at = ? WHERE id = ? + UPDATE run_change_sets SET file_count = ?, updated_at = ? WHERE id = ? AND file_count <> ? `) const finalizeSet = database.prepare(` UPDATE run_change_sets SET status = 'completed', file_count = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND (status <> 'completed' OR file_count <> ?) `) return { completeCapture(id, after, toolReportedError, completedAt) { - completeCapture.run( + return Number(completeCapture.run( after.kind, after.sizeBytes, after.hash, @@ -172,7 +172,7 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo toolReportedError ? 1 : 0, completedAt, id, - ) + ).changes) === 1 }, createCapture(record) { insertCapture.run( @@ -207,7 +207,7 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo } | undefined if (run?.conversation_id !== record.conversationId || run.deleted_at !== null) throw new Error('Lexora Buddy change-set ownership is invalid') - ensureSet.run( + const result = ensureSet.run( record.id, record.runId, record.conversationId, @@ -217,7 +217,7 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo record.createdAt, record.updatedAt, ) - return requireChangeSet(findSet.get(record.id), record.id) + return { created: Number(result.changes) === 1, record: requireChangeSet(findSet.get(record.id), record.id) } }, findCaptureByToolCallId(toolCallId) { const row = findCapture.get(toolCallId) as FileCaptureRow | undefined @@ -232,7 +232,7 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo return row ? toChangeSet(row) : null }, finalizeSet(id, fileCount, updatedAt) { - finalizeSet.run(fileCount, updatedAt, id) + return Number(finalizeSet.run(fileCount, updatedAt, id, fileCount).changes) === 1 }, listCaptures(changeSetId) { return (listCaptures.all(changeSetId) as unknown as FileCaptureRow[]).map(toFileCapture) @@ -252,10 +252,10 @@ export function createChangeSetRepository(database: DatabaseSync): ChangeSetRepo return rows.map(toChangeSet) }, markPartial(id, updatedAt) { - markPartial.run(updatedAt, id) + return Number(markPartial.run(updatedAt, id).changes) === 1 }, updateFileCount(id, fileCount, updatedAt) { - updateFileCount.run(fileCount, updatedAt, id) + return Number(updateFileCount.run(fileCount, updatedAt, id, fileCount).changes) === 1 }, } } diff --git a/apps/buddy/service/src/chat/ChatCommandService.ts b/apps/buddy/service/src/chat/ChatCommandService.ts index 6ccde700..4827a395 100644 --- a/apps/buddy/service/src/chat/ChatCommandService.ts +++ b/apps/buddy/service/src/chat/ChatCommandService.ts @@ -1,10 +1,12 @@ import type { BuddyComposerDraftSend } from '../../../shared/conversation/composerDraft' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { BuddyTurnLauncher } from '../agent/execution/BuddyTurnLauncher' import type { ConversationLifecycleService } from '../conversations/ConversationLifecycleService' import type { CommandRequestRecord, CommandRequestRepository, } from '../storage/commandRequestRepository' +import type { ComposerDraftCommitReceipt } from '../storage/commitComposerDraft' import type { ComposerDraftRepository } from '../storage/composerDraftRepository' import type { ConversationRepository } from '../storage/conversationRepository' import type { RunRecord } from '../storage/runRecord' @@ -13,12 +15,26 @@ import type { SpaceRepository } from '../storage/spaceRepository' import { randomUUID } from 'node:crypto' import { parseBuddyChatCommand } from '../../../shared/conversation/buddyChatCommands' import { buddyUserContentToText, getBuddyUserContentResourceIds } from '../../../shared/conversation/buddyUserContent' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { BuddyServiceError } from '../rpc/runtimeRequest' import { toPublicRun } from '../runs/publicRun' import { requireActiveSpace } from '../spaces/requireActiveSpace' export type ExecuteChatCommandInput = BuddyComposerDraftSend +export type ChatCommandCommit = EventSnapshot<{ + commitId: string + requestId: string + conversationId: string + branchId: string + facts: [ + { kind: 'command.accepted', command: 'compact' }, + { kind: 'run.queued', runId: string, purpose: 'conversation.compaction' }, + { kind: 'draft.consumed' } & ComposerDraftCommitReceipt, + ] +}> + export interface ChatCommandServiceOptions { commands: CommandRequestRepository conversationLifecycle: Pick @@ -27,16 +43,37 @@ export interface ChatCommandServiceOptions { spaces: Pick runs: Pick turnLauncher: Pick + onObserverError?: (error: unknown) => void } export class ChatCommandService { readonly #options: ChatCommandServiceOptions + readonly #committed: Emitter + readonly #stopping = new AbortController() + readonly #pending = new Set>() + readonly onDidCommit: Emitter['event'] constructor(options: ChatCommandServiceOptions) { this.#options = options + this.#committed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCommit = this.#committed.event + } + + execute(input: ExecuteChatCommandInput): Promise> { + const pending = Promise.withResolvers>() + this.#pending.add(pending.promise) + void this.#execute({ ...input }).then((value) => { + this.#pending.delete(pending.promise) + pending.resolve(value) + }, (error) => { + this.#pending.delete(pending.promise) + pending.reject(error) + }) + return pending.promise } - async execute(input: ExecuteChatCommandInput) { + async #execute(input: ExecuteChatCommandInput) { + this.#stopping.signal.throwIfAborted() const requestFingerprint = createCommandFingerprint(input) const replay = this.#options.commands.findByRequestId(input.requestId) const replayRun = replay ? this.#requireRun(replay.runId) : null @@ -98,11 +135,28 @@ export class ChatCommandService { if (!prepared.created) return toTurnStart(prepared, this.#requireRun(prepared.runId)) - const operation = await this.#options.turnLauncher.launch(prepared.runId) - void operation.completion + this.#committed.fire(copyEventSnapshot({ + commitId: prepared.runId, + requestId: prepared.requestId, + conversationId: prepared.conversationId, + branchId: prepared.branchId, + facts: [ + { kind: 'command.accepted', command: prepared.command }, + { kind: 'run.queued', runId: prepared.runId, purpose: 'conversation.compaction' }, + { kind: 'draft.consumed', ...prepared.draftReceipt }, + ], + })) + const operation = await this.#options.turnLauncher.launch(prepared.runId, this.#stopping.signal) + void operation.completion.catch(() => {}) return toTurnStart(prepared, this.#requireRun(operation.runId)) } + async dispose(): Promise { + this.#stopping.abort() + await Promise.allSettled(this.#pending) + this.#committed.dispose() + } + #requireRun(runId: string): RunRecord { return requireValue(this.#options.runs.findById(runId)) } diff --git a/apps/buddy/service/src/chat/ChatQueueService.ts b/apps/buddy/service/src/chat/ChatQueueService.ts index d3afa64b..bcced3fd 100644 --- a/apps/buddy/service/src/chat/ChatQueueService.ts +++ b/apps/buddy/service/src/chat/ChatQueueService.ts @@ -1,15 +1,19 @@ -import type { LocalChatQueueScope, LocalChatQueueTarget } from '../../../shared/conversation/chatQueueApi' +import type { LocalChatQueueReceipt, LocalChatQueueScope, LocalChatQueueTarget } from '../../../shared/conversation/chatQueueApi' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { BuddyAgentRunner } from '../agent/execution/BuddyAgentRunner' import type { BuddyTurnLauncher } from '../agent/execution/BuddyTurnLauncher' import type { BuddyStartTurnInput } from '../BuddyRuntime' +import type { RunEventObservation } from '../events/RunEventPorts' import type { ChatQueueRepository } from '../storage/chatQueueRepository' import type { RunInputRepository } from '../storage/runInputRepository' import type { RunRecord } from '../storage/runRecord' import type { RunRepository } from '../storage/runRepository' import type { PrepareTurnRequestInput, TurnRequestRepository } from '../storage/turnRequestRepository' import type { ChatTurnService } from './ChatTurnService' -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { isDocumentMimeType } from '../../../shared/conversation/attachmentFormats' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { createBuddyInputReference } from '../agent/context/BuddyInputReference' import { getAttachmentLabels } from '../attachments/attachmentLabels' import { BuddyServiceError } from '../rpc/runtimeRequest' @@ -20,30 +24,77 @@ export interface ChatQueueServiceOptions { turns: Pick requests: Pick launcher: Pick - runner: Pick + runner: Pick + eventLog: Pick + onObserverError?: (error: unknown) => void runInputs: Pick runs: Pick } +export type ChatQueueChange = EventSnapshot<{ + kind: 'enqueued' | 'cancelled' | 'delivered' | 'dispatched' | 'paused' + scope: LocalChatQueueScope + committed?: { commitId: string, requestId: string, queueId: string, runId?: string, messageId?: string, draftReceipt?: LocalChatQueueReceipt['draftReceipt'], attachmentOwnership?: { kind: 'queue' | 'message', attachmentIds: readonly string[] } } +}> + export class ChatQueueService { readonly #options: ChatQueueServiceOptions readonly #operations = new Map>() + readonly #enqueues = new Set>() + readonly #changes: Emitter + readonly #stopping = new AbortController() + readonly onDidChange: Emitter['event'] #disposed = false constructor(options: ChatQueueServiceOptions) { this.#options = options + this.#changes = new Emitter(options.onObserverError ?? (() => {})) + this.onDidChange = this.#changes.event options.queue.pause() } dispose() { this.#disposed = true + this.#stopping.abort() + this.#options.queue.pause() + this.#changes.dispose() + } + + async drain(): Promise { + await Promise.allSettled([...this.#operations.values(), ...this.#enqueues]) + } + + continuationScopes(): LocalChatQueueScope[] { + return this.#options.queue.continuationScopes() + } + + pause(scope: LocalChatQueueScope): void { + if (this.#disposed) + return + if (this.#options.queue.pause(scope.conversationId)) + this.#changed('paused', scope) } list(scope: LocalChatQueueScope) { return this.#options.queue.list(scope) } - async enqueue(input: BuddyStartTurnInput) { + enqueue(input: BuddyStartTurnInput): Promise { + const pending = Promise.withResolvers() + this.#enqueues.add(pending.promise) + void this.#enqueue({ ...input }).then((value) => { + this.#enqueues.delete(pending.promise) + pending.resolve(value) + }, (error) => { + this.#enqueues.delete(pending.promise) + pending.reject(error) + }) + return pending.promise + } + + async #enqueue(input: BuddyStartTurnInput) { + if (this.#disposed) + throw new BuddyServiceError('VALIDATION_FAILED') const fingerprint = createHash('sha256').update(JSON.stringify([input.draftId, input.expectedRevision])).digest('hex') const replay = this.#options.queue.replay(input.requestId, fingerprint) if (replay) @@ -58,21 +109,24 @@ export class ChatQueueService { await stagedAttachments.rollback() return concurrent } + stagedAttachments.validate() result = this.#options.queue.enqueue({ ...prepared, requestFingerprint: fingerprint }) } catch (error) { await stagedAttachments.rollback() throw error } + this.#changed('enqueued', result, { commitId: randomUUID(), requestId: input.requestId, queueId: result.id, draftReceipt: result.draftReceipt, ...(prepared.attachmentBindings.length ? { attachmentOwnership: { kind: 'queue', attachmentIds: prepared.attachmentBindings.map(binding => binding.id) } } : {}) }) await stagedAttachments.commit().catch(() => undefined) - this.#schedule(result) return result } cancel(target: LocalChatQueueTarget) { + if (this.#disposed) + return false const cancelled = this.#options.queue.cancel(target) if (cancelled) - this.#schedule(target) + this.#changed('cancelled', target) return cancelled } @@ -90,7 +144,7 @@ export class ChatQueueService { if (!run || run.approvalPolicy !== input.approvalPolicy || run.executionProfile !== resolveTurnExecutionProfile(input)) return false await this.#options.turns.validatePreparedInput(input, false) - if (this.#disposed || !this.#options.queue.pending(target) || this.#options.queue.activeRun(target)?.id !== active.id) + if (this.#disposed || this.#options.eventLog.state !== 'open' || !this.#options.queue.pending(target) || this.#options.queue.activeRun(target)?.id !== active.id) return false return this.#deliver(input, active.id, 'steer') }) @@ -111,7 +165,7 @@ export class ChatQueueService { if (!input || !this.#canFollowUp(run, input)) return false await this.#options.turns.validatePreparedInput(input) - if (this.#disposed || signal.aborted) + if (this.#disposed || this.#options.eventLog.state !== 'open' || signal.aborted) return false const head = this.#options.queue.list(run)[0] if (this.#options.queue.activeRun(run)?.id !== runId @@ -124,7 +178,7 @@ export class ChatQueueService { } catch { if (!this.#disposed) - this.#options.queue.pause(run.conversationId) + this.pause(run) return false } } @@ -156,62 +210,55 @@ export class ChatQueueService { }), }) this.#options.queue.commitInRun(input, runId) + this.#changed('delivered', input, { commitId: randomUUID(), requestId: input.requestId, queueId: input.queuedMessageId!, runId, messageId: input.userMessageId, ...(input.attachmentBindings.length ? { attachmentOwnership: { kind: 'message', attachmentIds: input.attachmentBindings.map(binding => binding.id) } } : {}) }) return reference }, input.runInput.contextItems.flatMap(item => item.kind === 'skill' && item.skill ? [item.skill] : [])) } - onRunSettled(runId: string) { - if (this.#disposed) - return - const run = this.#options.runs.findById(runId) - if (!run) - return - if (run.status !== 'completed') { - this.#options.queue.pause(run.conversationId) - return - } - this.#schedule(run) - } - - #schedule(scope: LocalChatQueueScope) { - setTimeout(() => { - void this.#dispatchNext(scope).catch(() => { - if (!this.#disposed) - this.#options.queue.pause(scope.conversationId) - }) - }, 0) - } - - #dispatchNext(scope: LocalChatQueueScope) { + reconcile(scope: LocalChatQueueScope) { return this.#serialize(scope, async () => { - if (this.#options.queue.activeRun(scope)) + const current = this.#options.queue.continuationScope(scope.conversationId) + if (!current || !this.#canDispatch(current)) return false - const next = this.#options.queue.list(scope)[0] + const latest = this.#options.queue.latestRun(current) + if ((latest && latest.status !== 'completed') || this.#options.runner.hasDegradedCleanup(current.conversationId)) { + this.pause(current) + return false + } + const next = this.#options.queue.list(current)[0] return next?.state === 'waiting' ? this.#dispatch(next) : false }) } async #dispatch(target: LocalChatQueueTarget, allowPaused = false) { - if (this.#disposed || this.#options.queue.activeRun(target)) + if (!this.#canDispatch(target)) return false const input = this.#options.queue.pending(target) if (!input) return false await this.#options.turns.validatePreparedInput(input) - if (this.#disposed || !this.#options.queue.pending(target) || this.#options.queue.activeRun(target)) + if (!this.#canDispatch(target) || !this.#options.queue.pending(target)) return false const head = this.#options.queue.list(target)[0] if (!allowPaused && (head?.id !== target.id || head.state !== 'waiting')) return false const prepared = this.#options.requests.prepare({ ...input, createdAt: new Date().toISOString() }) - const turn = await this.#options.launcher.launch(prepared.runId) - void turn.completion.then(() => this.onRunSettled(turn.runId), () => { - if (!this.#disposed) - this.#options.queue.pause(target.conversationId) - }) + this.#changed('dispatched', target) + const turn = await this.#options.launcher.launch(prepared.runId, this.#stopping.signal) + void turn.completion.catch(() => {}) return true } + #canDispatch(scope: LocalChatQueueScope): boolean { + return !this.#disposed && !this.#options.runner.isStopping && this.#options.eventLog.state === 'open' + && !this.#options.queue.activeRun(scope) + && !this.#options.runner.hasActiveExecution(scope.conversationId) + } + + #changed(kind: ChatQueueChange['kind'], scope: LocalChatQueueScope, committed?: ChatQueueChange['committed']): void { + this.#changes.fire(copyEventSnapshot({ kind, scope: { conversationId: scope.conversationId, branchId: scope.branchId }, ...(committed ? { committed } : {}) })) + } + async #serialize(scope: LocalChatQueueScope, operation: () => Promise): Promise { const previous = this.#operations.get(scope.conversationId) const { promise, resolve } = Promise.withResolvers() diff --git a/apps/buddy/service/src/chat/ChatTurnService.ts b/apps/buddy/service/src/chat/ChatTurnService.ts index e5681b61..f07697d3 100644 --- a/apps/buddy/service/src/chat/ChatTurnService.ts +++ b/apps/buddy/service/src/chat/ChatTurnService.ts @@ -5,7 +5,6 @@ import type { } from '../../../shared/conversation/buddyUserContent' import type { BuddyComposerDraftScope } from '../../../shared/conversation/composerDraft' import type { BuddyThinkingLevel } from '../../../shared/conversation/modelSelection' -import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { BuddyAgentRunner } from '../agent/execution/BuddyAgentRunner' import type { BuddyTurnLauncher } from '../agent/execution/BuddyTurnLauncher' import type { @@ -40,6 +39,7 @@ import type { TurnRequestRepository, } from '../storage/turnRequestRepository' import type { ChatInputHistoryPoint, ChatInputValidationService } from './ChatInputValidationService' +import type { PreparedTurnAttachments } from './persistPreparedTurn' import { Buffer } from 'node:buffer' import { createHash, randomUUID } from 'node:crypto' import { isAbsolute, join } from 'node:path' @@ -57,7 +57,6 @@ import { getResourceAttachmentIds, } from '../../../shared/conversation/buddyUserContent' import { isBuddyThinkingLevel } from '../../../shared/conversation/modelSelection' -import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import { isExecutionProfileWithin } from '../../../shared/permissions/executionProfile' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { getModelFileInputMimeTypes } from '../providers/modelCapabilities' @@ -71,7 +70,7 @@ import { import { requireActiveSpace } from '../spaces/requireActiveSpace' import { BUDDY_REVIEW_PROMPT, buildBuddyReviewPrompt } from './buddyReviewPrompt' import { createConversationTitle } from './conversationTitle' -import { persistPreparedTurn } from './persistPreparedTurn' +import { combinePreparedAttachments, persistPreparedTurn } from './persistPreparedTurn' const MAX_CONTEXT_FILE_BYTES = 1024 * 1024 const MAX_MODEL_INPUT_BYTES = 4 * 1024 * 1024 @@ -94,7 +93,6 @@ export interface RegenerateChatAssistantInput { } export interface ChatTurnServiceOptions { - record?: ApplicationDiagnosticReporter composerResources?: Pick attachments: Pick< AttachmentService, @@ -146,12 +144,18 @@ interface PrepareTurnMaterializationInput { export class ChatTurnService { readonly #options: ChatTurnServiceOptions + readonly #stopping = new AbortController() + readonly #pending = new Set>() constructor(options: ChatTurnServiceOptions) { this.#options = options } - async start(input: BuddyStartTurnInput): Promise { + start(input: BuddyStartTurnInput): Promise { + return this.#run(() => this.#start({ ...input })) + } + + async #start(input: BuddyStartTurnInput): Promise { const replay = this.#findReplay( input.requestId, createStartTurnFingerprint(input), @@ -159,11 +163,15 @@ export class ChatTurnService { if (replay) return this.#toTurnStart(replay.request, replay.run) const { prepared, stagedAttachments } = await this.prepareStart(input) - const request = await persistPreparedTurn(stagedAttachments, () => this.#options.turnRequests.prepare(prepared)) + const request = await persistPreparedTurn(stagedAttachments, () => { + this.#stopping.signal.throwIfAborted() + return this.#options.turnRequests.prepare(prepared) + }) return this.#launchPreparedTurn(request) } async prepareStart(input: BuddyStartTurnInput) { + this.#stopping.signal.throwIfAborted() const draft = this.#options.drafts.findById(input.draftId) if (!draft || draft.revision !== input.expectedRevision) throw new BuddyServiceError('DRAFT_CONFLICT') @@ -215,85 +223,93 @@ export class ChatTurnService { throw new BuddyServiceError('VALIDATION_FAILED') const selectedModel = await this.#resolveSelection(null, null, draft.modelSelection) - const resourceInputs = await requireValue(this.#options.composerResources ?? null) + const materialized = await requireValue(this.#options.composerResources ?? null) .resolveInput(input.draftId, draft.content, { branchId: existingConversation ? parentBranchId : null, conversationId: existingConversation?.id ?? null, spaceId: space?.id ?? null, }, selectedModel) - if (!content && resourceInputs.length === 0 && !draft.content.quotes?.length) - throw new BuddyServiceError('VALIDATION_FAILED') - const attachmentIds = getResourceAttachmentIds(resourceInputs) + let stagedAttachments: PreparedTurnAttachments | null = null + try { + const resourceInputs = materialized.inputs + if (!content && resourceInputs.length === 0 && !draft.content.quotes?.length) + throw new BuddyServiceError('VALIDATION_FAILED') + const attachmentIds = getResourceAttachmentIds(resourceInputs) - const { - attachmentPrompt, - prompt, - reviewRequested, - contextItems: resolvedContextItems, - selection, - thinkingLevel, - } = await this.#prepareTurnMaterialization({ - attachmentIds, - composer: { - content: draft.content, - resourceIds: resourceInputs.map(resource => resource.resourceId), - resources: resourceInputs, - }, - content: '', - contextItems: [], - conversationId, - draftId: input.draftId, - branchId: parentBranchId, - point: !existingConversation ? { kind: 'empty' } : followup ? { kind: 'after_run', runId: followup.sourceRunId, messageId: followup.sourceMessageId } : undefined, - space, - replay: null, - requestedModel: draft.modelSelection, - preparedSelection: selectedModel, - }) - const runId = randomUUID() - const userMessageId = randomUUID() - const stagedAttachments = await this.#options.attachments.prepareMessageAttachments({ - attachmentIds, - conversationId, - draftId: input.draftId, - messageId: userMessageId, - }) - const persistedAttachmentIds = stagedAttachments.bindings.map(binding => binding.id) - const prepared: PrepareTurnRequestInput = { - followup, - approvalPolicy: draft.executionConfig.approvalPolicy, - attachmentBindings: stagedAttachments.bindings, - branchId, - conversationId, - createdAt: new Date().toISOString(), - draft: { - draftId: input.draftId, - expectedRevision: input.expectedRevision, - }, - executionProfile: draft.executionConfig.executionProfile, - runExecutionProfile: reviewRequested ? 'read_only' : undefined, - model: selection.modelId, - modelParameters: toModelParameters(selection), - spaceId: space?.id ?? null, - provider: selection.providerId, - requestFingerprint: createStartTurnFingerprint(input), - requestId: input.requestId, - runInput: { - attachmentIds: persistedAttachmentIds, - contextItems: resolvedContextItems, + const { + attachmentPrompt, prompt, - reasoning: thinkingLevel ?? null, - serviceTier: selection.serviceTier, - }, - runId, - title: createConversationTitle(draft.content, attachmentPrompt.records), - userMessageContent: createPersistedUserMessageContent( - draft.content, - bindResourceAttachments(resourceInputs, persistedAttachmentIds), - ), - userMessageId, + reviewRequested, + contextItems: resolvedContextItems, + selection, + thinkingLevel, + } = await this.#prepareTurnMaterialization({ + attachmentIds, + composer: { + content: draft.content, + resourceIds: resourceInputs.map(resource => resource.resourceId), + resources: resourceInputs, + }, + content: '', + contextItems: [], + conversationId, + draftId: input.draftId, + branchId: parentBranchId, + point: !existingConversation ? { kind: 'empty' } : followup ? { kind: 'after_run', runId: followup.sourceRunId, messageId: followup.sourceMessageId } : undefined, + space, + replay: null, + requestedModel: draft.modelSelection, + preparedSelection: selectedModel, + }) + const runId = randomUUID() + const userMessageId = randomUUID() + stagedAttachments = combinePreparedAttachments(await this.#options.attachments.prepareMessageAttachments({ + attachmentIds, + conversationId, + draftId: input.draftId, + messageId: userMessageId, + }), materialized) + const persistedAttachmentIds = stagedAttachments.bindings.map(binding => binding.id) + const prepared: PrepareTurnRequestInput = { + followup, + approvalPolicy: draft.executionConfig.approvalPolicy, + attachmentBindings: stagedAttachments.bindings, + branchId, + conversationId, + createdAt: new Date().toISOString(), + draft: { + draftId: input.draftId, + expectedRevision: input.expectedRevision, + }, + executionProfile: draft.executionConfig.executionProfile, + runExecutionProfile: reviewRequested ? 'read_only' : undefined, + model: selection.modelId, + modelParameters: toModelParameters(selection), + spaceId: space?.id ?? null, + provider: selection.providerId, + requestFingerprint: createStartTurnFingerprint(input), + requestId: input.requestId, + runInput: { + attachmentIds: persistedAttachmentIds, + contextItems: resolvedContextItems, + prompt, + reasoning: thinkingLevel ?? null, + serviceTier: selection.serviceTier, + }, + runId, + title: createConversationTitle(draft.content, attachmentPrompt.records), + userMessageContent: createPersistedUserMessageContent( + draft.content, + bindResourceAttachments(resourceInputs, persistedAttachmentIds), + ), + userMessageId, + } + return { prepared, stagedAttachments } + } + catch (error) { + await (stagedAttachments ?? materialized).rollback() + throw error } - return { prepared, stagedAttachments } } async validatePreparedInput(input: PrepareTurnRequestInput, validateSkills = true): Promise { @@ -311,7 +327,11 @@ export class ChatTurnService { }) } - async editUserMessage(input: EditChatUserMessageInput) { + editUserMessage(input: EditChatUserMessageInput) { + return this.#run(() => this.#editUserMessage({ ...input })) + } + + async #editUserMessage(input: EditChatUserMessageInput) { const replay = this.#findReplay( input.requestId, createEditUserMessageFingerprint(input), @@ -347,99 +367,116 @@ export class ChatTurnService { const space = this.#resolveConversationSpace(conversation) const content = draft ? buddyUserContentToText(draft.content).trim() : '' const selectedModel = draft ? await this.#resolveSelection(null, null, draft.modelSelection) : undefined - const resourceInputs = draft + const materialized = draft && !replay ? await requireValue(this.#options.composerResources ?? null).resolveInput( draft.draftId, draft.content, { branchId: parentBranchId, conversationId: conversation.id, spaceId: space?.id ?? null }, selectedModel, ) - : [] - if (!replay && !content && resourceInputs.length === 0 && !draft?.content.quotes?.length) - throw new BuddyServiceError('VALIDATION_FAILED') - const attachmentIds = getResourceAttachmentIds(resourceInputs) - const { - prompt, - replayInput, - reviewRequested, - contextItems: resolvedContextItems, - selection, - thinkingLevel, - } = await this.#prepareTurnMaterialization({ - attachmentIds, - composer: draft - ? { content: draft.content, resourceIds: resourceInputs.map(resource => resource.resourceId), resources: resourceInputs } - : undefined, - content: '', - contextItems: [], - conversationId: conversation.id, - draftId: input.draftId, - branchId: parentBranchId, - point: { kind: 'before_message', messageId: input.userMessageId }, - space, - replay, - requestedModel: draft?.modelSelection ?? null, - preparedSelection: selectedModel, - }) - const runId = randomUUID() - const userMessageId = randomUUID() - const stagedAttachments = replay - ? null - : await this.#options.attachments.prepareMessageAttachments({ - attachmentIds, - conversationId: conversation.id, - draftId: input.draftId, - messageId: userMessageId, - }) - const persistedAttachmentIds = replayInput?.attachmentIds - ?? stagedAttachments?.bindings.map(binding => binding.id) - ?? [] - const persistedResourceSnapshots = replay ? [] : bindResourceAttachments(resourceInputs, persistedAttachmentIds) - const prepared = await persistPreparedTurn(stagedAttachments, () => ( - replay - ? this.#options.turnRequests.retryInterrupted({ - createdAt: new Date().toISOString(), - requestId: input.requestId, - runId, - }) - : this.#options.turnRequests.edit({ - approvalPolicy: conversation.approvalPolicy, - attachmentBindings: stagedAttachments?.bindings ?? [], - branchId: randomUUID(), + : null + let stagedAttachments: PreparedTurnAttachments | null = null + let persistenceOwnsAttachments = false + let prepared: TurnRequestRecord + try { + const resourceInputs = materialized?.inputs ?? [] + if (!replay && !content && resourceInputs.length === 0 && !draft?.content.quotes?.length) + throw new BuddyServiceError('VALIDATION_FAILED') + const attachmentIds = getResourceAttachmentIds(resourceInputs) + const { + prompt, + replayInput, + reviewRequested, + contextItems: resolvedContextItems, + selection, + thinkingLevel, + } = await this.#prepareTurnMaterialization({ + attachmentIds, + composer: draft + ? { content: draft.content, resourceIds: resourceInputs.map(resource => resource.resourceId), resources: resourceInputs } + : undefined, + content: '', + contextItems: [], + conversationId: conversation.id, + draftId: input.draftId, + branchId: parentBranchId, + point: { kind: 'before_message', messageId: input.userMessageId }, + space, + replay, + requestedModel: draft?.modelSelection ?? null, + preparedSelection: selectedModel, + }) + const runId = randomUUID() + const userMessageId = randomUUID() + stagedAttachments = replay + ? null + : combinePreparedAttachments(await this.#options.attachments.prepareMessageAttachments({ + attachmentIds, conversationId: conversation.id, - createdAt: new Date().toISOString(), - draft: { draftId: input.draftId, expectedRevision: input.expectedRevision }, - executionProfile: conversation.executionProfile, - runExecutionProfile: reviewRequested ? 'read_only' : undefined, - forkedFromMessageId, - model: selection.modelId, - modelParameters: toModelParameters(selection), - parentBranchId, - spaceId: space?.id ?? null, - provider: selection.providerId, - requestFingerprint: createEditUserMessageFingerprint(input), - requestId: input.requestId, - runId, - runInput: { - attachmentIds: persistedAttachmentIds, - contextItems: resolvedContextItems, - prompt, - reasoning: thinkingLevel ?? null, - serviceTier: replayInput ? replayInput.serviceTier : selection.serviceTier, - }, - sourceUserMessageId: input.userMessageId, - title: null, - userMessageContent: createPersistedUserMessageContent( - draft!.content, - persistedResourceSnapshots, - ), - userMessageId, - }) - )) + draftId: input.draftId, + messageId: userMessageId, + }), requireValue(materialized)) + const persistedAttachmentIds = replayInput?.attachmentIds + ?? stagedAttachments?.bindings.map(binding => binding.id) + ?? [] + const persistedResourceSnapshots = replay ? [] : bindResourceAttachments(resourceInputs, persistedAttachmentIds) + persistenceOwnsAttachments = true + prepared = await persistPreparedTurn(stagedAttachments, () => { + this.#stopping.signal.throwIfAborted() + return replay + ? this.#options.turnRequests.retryInterrupted({ + createdAt: new Date().toISOString(), + requestId: input.requestId, + runId, + }) + : this.#options.turnRequests.edit({ + approvalPolicy: conversation.approvalPolicy, + attachmentBindings: stagedAttachments?.bindings ?? [], + branchId: randomUUID(), + conversationId: conversation.id, + createdAt: new Date().toISOString(), + draft: { draftId: input.draftId, expectedRevision: input.expectedRevision }, + executionProfile: conversation.executionProfile, + runExecutionProfile: reviewRequested ? 'read_only' : undefined, + forkedFromMessageId, + model: selection.modelId, + modelParameters: toModelParameters(selection), + parentBranchId, + spaceId: space?.id ?? null, + provider: selection.providerId, + requestFingerprint: createEditUserMessageFingerprint(input), + requestId: input.requestId, + runId, + runInput: { + attachmentIds: persistedAttachmentIds, + contextItems: resolvedContextItems, + prompt, + reasoning: thinkingLevel ?? null, + serviceTier: replayInput ? replayInput.serviceTier : selection.serviceTier, + }, + sourceUserMessageId: input.userMessageId, + title: null, + userMessageContent: createPersistedUserMessageContent( + draft!.content, + persistedResourceSnapshots, + ), + userMessageId, + }) + }) + } + catch (error) { + if (!persistenceOwnsAttachments) + await (stagedAttachments ?? materialized)?.rollback() + throw error + } return this.#launchPreparedTurn(prepared) } - async regenerateAssistant(input: RegenerateChatAssistantInput) { + regenerateAssistant(input: RegenerateChatAssistantInput) { + return this.#run(() => this.#regenerateAssistant({ ...input })) + } + + async #regenerateAssistant(input: RegenerateChatAssistantInput) { const replay = this.#findReplay( input.requestId, createRegenerationFingerprint(input), @@ -473,6 +510,7 @@ export class ChatTurnService { attachments: this.#options.attachments.getInputMetadata(storedInput.attachmentIds, conversation.id), }) const runId = randomUUID() + this.#stopping.signal.throwIfAborted() const prepared = replay ? this.#options.turnRequests.retryInterrupted({ createdAt: new Date().toISOString(), @@ -521,21 +559,33 @@ export class ChatTurnService { } async #launchPreparedTurn(prepared: TurnRequestRecord) { - safeDiagnosticReporter(this.#options.record)({ - event: prepared.created ? 'run.queued' : 'run.reused', - level: 'info', - runId: prepared.runId, - conversationId: prepared.conversationId, - branchId: prepared.branchId, - requestId: prepared.requestId, - }) if (!prepared.created) return this.#toTurnStart(prepared, this.#requireRun(prepared.runId)) - const turn = await this.#options.turnLauncher.launch(prepared.runId) - void turn.completion + const turn = await this.#options.turnLauncher.launch(prepared.runId, this.#stopping.signal) + void turn.completion.catch(() => {}) return this.#toTurnStart(prepared, this.#requireRun(turn.runId)) } + async dispose(): Promise { + this.#stopping.abort() + await Promise.allSettled(this.#pending) + } + + #run(operation: () => Promise): Promise { + if (this.#stopping.signal.aborted) + return Promise.reject(this.#stopping.signal.reason) + const result = Promise.withResolvers() + this.#pending.add(result.promise) + void operation().then((value) => { + this.#pending.delete(result.promise) + result.resolve(value) + }, (error) => { + this.#pending.delete(result.promise) + result.reject(error) + }) + return result.promise + } + #publicRun(run: RunRecord) { return toPublicRun(run, this.#options.runInputs.findByRunId(run.id)?.reasoning ?? null) } diff --git a/apps/buddy/service/src/chat/ComposerDraftService.ts b/apps/buddy/service/src/chat/ComposerDraftService.ts index c382e358..bddac638 100644 --- a/apps/buddy/service/src/chat/ComposerDraftService.ts +++ b/apps/buddy/service/src/chat/ComposerDraftService.ts @@ -4,17 +4,46 @@ import type { BuddyComposerDraftOpen, BuddyComposerDraftSave, } from '../../../shared/conversation/composerDraft' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { ComposerDraftRepository } from '../storage/composerDraftRepository' +import { randomUUID } from 'node:crypto' +import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { BuddyServiceError } from '../rpc/runtimeRequest' +export type ComposerDraftCommit = EventSnapshot<{ + operationId: string + kind: 'opened' | 'saved' | 'discarded' + draftId: string + revision: number + scope: BuddyComposerDraft['scope'] +}> + +export type ComposerDraftCleanup = Readonly<{ + operationId: string + draftId: string + status: 'started' | 'completed' | 'failed' + errorCode?: string +}> + export class ComposerDraftService { readonly #repository: ComposerDraftRepository readonly #releaseResources: (draftId: string) => Promise readonly #pending = new Map>() + readonly #committed: Emitter + readonly #cleanup: Emitter + readonly onDidCommit: Emitter['event'] + readonly onDidCleanup: Emitter['event'] + #stopping = false - constructor(repository: ComposerDraftRepository, releaseResources: (draftId: string) => Promise) { + constructor(repository: ComposerDraftRepository, releaseResources: (draftId: string) => Promise, onObserverError: (error: unknown) => void = () => {}) { this.#repository = repository this.#releaseResources = releaseResources + this.#committed = new Emitter(onObserverError) + this.#cleanup = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + this.onDidCleanup = this.#cleanup.event } list(): BuddyComposerDraft[] { @@ -33,19 +62,44 @@ export class ComposerDraftService { } open(input: BuddyComposerDraftOpen): Promise { - return this.#enqueue(input.draftId, () => this.#repository.open({ ...input, now: new Date().toISOString() })) + input = structuredClone(input) + return this.#enqueue(input.draftId, () => { + const existing = this.#repository.findByScope(input.scope) + const draft = this.#repository.open({ ...input, now: new Date().toISOString() }) + if (!existing) + this.#publish('opened', draft) + return draft + }) } save(input: BuddyComposerDraftSave): Promise { - return this.run(input.draftId, () => this.#repository.save({ ...input, now: new Date().toISOString() })) + input = structuredClone(input) + return this.run(input.draftId, () => { + const draft = this.#repository.save({ ...input, now: new Date().toISOString() }) + this.#publish('saved', draft) + return draft + }) } discard(input: BuddyComposerDraftDiscard): Promise { + input = { ...input } return this.#enqueue(input.draftId, async () => { + const existing = this.#repository.findById(input.draftId) if (!this.#repository.discard(input)) return false - await this.#releaseResources(input.draftId) - return true + if (existing) + this.#publish('discarded', existing) + const operationId = randomUUID() + this.#cleanup.fire(copyEventSnapshot({ operationId, draftId: input.draftId, status: 'started' })) + try { + await this.#releaseResources(input.draftId) + this.#cleanup.fire(copyEventSnapshot({ operationId, draftId: input.draftId, status: 'completed' })) + return true + } + catch (error) { + this.#cleanup.fire(copyEventSnapshot({ operationId, draftId: input.draftId, status: 'failed', errorCode: readDiagnosticErrorCode(error) })) + throw error + } }) } @@ -57,6 +111,8 @@ export class ComposerDraftService { } #enqueue(draftId: string, operation: () => T | Promise): Promise { + if (this.#stopping) + return Promise.reject(new BuddyServiceError('VALIDATION_FAILED')) const next = (this.#pending.get(draftId) ?? Promise.resolve()).catch(() => {}).then(operation) this.#pending.set(draftId, next) const release = () => { @@ -66,4 +122,15 @@ export class ComposerDraftService { void next.then(release, release) return next } + + async dispose(): Promise { + this.#stopping = true + await Promise.allSettled(this.#pending.values()) + this.#committed.dispose() + this.#cleanup.dispose() + } + + #publish(kind: ComposerDraftCommit['kind'], draft: BuddyComposerDraft): void { + this.#committed.fire(copyEventSnapshot({ operationId: randomUUID(), kind, draftId: draft.draftId, revision: draft.revision, scope: draft.scope })) + } } diff --git a/apps/buddy/service/src/chat/QueueContinuation.ts b/apps/buddy/service/src/chat/QueueContinuation.ts new file mode 100644 index 00000000..c73da4f8 --- /dev/null +++ b/apps/buddy/service/src/chat/QueueContinuation.ts @@ -0,0 +1,161 @@ +import type { LocalChatQueueScope } from '../../../shared/conversation/chatQueueApi' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { BuddyAgentRunner } from '../agent/execution/BuddyAgentRunner' +import type { RunEventObservation } from '../events/RunEventPorts' +import type { RunRepository } from '../storage/runRepository' +import type { ChatQueueService } from './ChatQueueService' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' + +type PendingScope = ({ scope: LocalChatQueueScope } | { runId: string }) & { attempts: number } + +export interface QueueContinuationOptions { + queue: Pick + runner: Pick + eventLog: RunEventObservation + runs: Pick + record?: ApplicationDiagnosticReporter +} + +export class QueueContinuation { + readonly #options: QueueContinuationOptions + readonly #record: ApplicationDiagnosticReporter + readonly #subscriptions: EventSubscription[] + readonly #dirty = new Map() + #timer: ReturnType | null = null + #running: Promise | null = null + #state: 'starting' | 'ready' | 'degraded' | 'stopped' = 'starting' + + constructor(options: QueueContinuationOptions) { + this.#options = options + this.#record = safeDiagnosticReporter(options.record) + this.#subscriptions = [ + options.queue.onDidChange((change) => { + if (change.kind === 'enqueued' || change.kind === 'cancelled' || change.kind === 'dispatched') + this.#request(change.scope) + }), + options.runner.onDidSettle((settled) => { + this.#request(settled) + }), + options.eventLog.onDidCommit((event) => { + if (!['run.completed', 'run.failed', 'run.cancelled'].includes(event.type)) + return + if (this.#state !== 'stopped') { + this.#dirty.set(`run:${event.runId}`, { runId: event.runId, attempts: 0 }) + this.#schedule() + } + }), + ] + this.reconcile() + } + + get state(): 'starting' | 'ready' | 'degraded' | 'stopped' { + return this.#state + } + + async start(): Promise { + if (this.#timer) + clearTimeout(this.#timer) + this.#timer = null + if (!this.#running) { + this.#running = this.#drain().finally(() => { + this.#running = null + this.#schedule() + }) + } + await this.#running + if (this.#state === 'degraded') + throw new Error('QUEUE_RECONCILIATION_FAILED') + } + + reconcile(): void { + if (this.#state === 'stopped') + return + for (const pending of this.#dirty.values()) + pending.attempts = 0 + for (const scope of this.#options.queue.continuationScopes()) + this.#request(scope) + if (this.#dirty.size === 0) + this.#state = 'ready' + else + this.#schedule() + } + + async dispose(): Promise { + this.#state = 'stopped' + if (this.#timer) + clearTimeout(this.#timer) + this.#timer = null + for (const subscription of this.#subscriptions) + subscription.dispose() + this.#dirty.clear() + await this.#running + } + + #request(scope: LocalChatQueueScope): void { + if (this.#state === 'stopped') + return + this.#dirty.set(`conversation:${scope.conversationId}`, { scope: { conversationId: scope.conversationId, branchId: scope.branchId }, attempts: 0 }) + this.#schedule() + } + + #schedule(): void { + if (this.#state === 'stopped' || this.#timer || this.#running) + return + const pending = [...this.#dirty.values()].filter(pending => pending.attempts < 3) + if (pending.length === 0) + return + const delay = Math.min(...pending.map(pending => pending.attempts)) * 50 + this.#timer = setTimeout(() => { + this.#timer = null + this.#running = this.#drain().finally(() => { + this.#running = null + this.#schedule() + }) + }, delay) + } + + async #drain(): Promise { + for (const [key, pending] of [...this.#dirty]) { + if (this.#state === 'stopped') + return + if (pending.attempts >= 3) + continue + this.#dirty.delete(key) + let scope: LocalChatQueueScope | null = 'scope' in pending ? pending.scope : null + try { + scope ??= 'runId' in pending ? this.#options.runs.findById(pending.runId) : null + if (!scope) + continue + if (this.#options.eventLog.state !== 'open') { + this.#options.queue.pause(scope) + this.#dirty.set(key, { ...pending, attempts: 3 }) + this.#state = 'degraded' + continue + } + await this.#options.queue.reconcile(scope) + if (this.state === 'stopped') + return + if (this.#options.eventLog.state !== 'open') { + this.#options.queue.pause(scope) + this.#dirty.set(key, { ...pending, attempts: 3 }) + this.#state = 'degraded' + } + } + catch { + if (this.state === 'stopped') + return + this.#dirty.set(key, { ...pending, attempts: pending.attempts + 1 }) + this.#state = 'degraded' + this.#record({ event: 'queue.reconcile_failed', level: 'error', conversationId: scope?.conversationId }) + try { + if (scope) + this.#options.queue.pause(scope) + } + catch {} + } + } + if (this.#state !== 'stopped' && this.#dirty.size === 0) + this.#state = 'ready' + } +} diff --git a/apps/buddy/service/src/chat/TurnRequestService.ts b/apps/buddy/service/src/chat/TurnRequestService.ts new file mode 100644 index 00000000..ec60f05a --- /dev/null +++ b/apps/buddy/service/src/chat/TurnRequestService.ts @@ -0,0 +1,60 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { EditTurnRequestInput, PrepareTurnRequestInput, RegenerateTurnRequestInput, RetryInterruptedTurnRequestInput, TurnCommitFact, TurnRequestRecord, TurnRequestRepository } from '../storage/turnRequestRepository' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' + +export type TurnRequestCommit = EventSnapshot<{ + commitId: string + requestId: string + runId: string + conversationId: string + branchId: string + facts: readonly TurnCommitFact[] +}> + +export class TurnRequestService implements TurnRequestRepository { + readonly #repository: TurnRequestRepository + readonly #committed: Emitter + readonly onDidCommit: Emitter['event'] + #disposed = false + + constructor(repository: TurnRequestRepository, onObserverError: (error: unknown) => void = () => {}) { + this.#repository = repository + this.#committed = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + } + + findByRequestId(requestId: string) { + return this.#repository.findByRequestId(requestId) + } + + prepare(input: PrepareTurnRequestInput) { + return this.#commit(() => this.#repository.prepare(input)) + } + + edit(input: EditTurnRequestInput) { + return this.#commit(() => this.#repository.edit(input)) + } + + regenerate(input: RegenerateTurnRequestInput) { + return this.#commit(() => this.#repository.regenerate(input)) + } + + retryInterrupted(input: RetryInterruptedTurnRequestInput) { + return this.#commit(() => this.#repository.retryInterrupted(input)) + } + + dispose(): void { + this.#disposed = true + this.#committed.dispose() + } + + #commit(operation: () => TurnRequestRecord): TurnRequestRecord { + if (this.#disposed) + throw new Error('Turn request service is stopped') + const request = operation() + if (request.created && request.committedFacts?.length) + this.#committed.fire(copyEventSnapshot({ commitId: request.runId, requestId: request.requestId, runId: request.runId, conversationId: request.conversationId, branchId: request.branchId, facts: request.committedFacts })) + return request + } +} diff --git a/apps/buddy/service/src/chat/__tests__/ChatCommandService.spec.ts b/apps/buddy/service/src/chat/__tests__/ChatCommandService.spec.ts new file mode 100644 index 00000000..ffd433c8 --- /dev/null +++ b/apps/buddy/service/src/chat/__tests__/ChatCommandService.spec.ts @@ -0,0 +1,101 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ChatCommandCommit } from '../ChatCommandService' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCommandRequestRepository } from '../../storage/commandRequestRepository' +import { createComposerDraftRepository } from '../../storage/composerDraftRepository' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' +import { createSpaceRepository } from '../../storage/spaceRepository' +import { ChatCommandService } from '../ChatCommandService' + +const databases: DatabaseSync[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) + +describe('command acceptance facts', () => { + it('publishes the complete transaction before launch and replays its receipt without another fact', async () => { + const f = fixture() + const commits: ChatCommandCommit[] = [] + f.service.onDidCommit((event) => { + expect(f.commands.findByRequestId(event.requestId)?.runId).toBe(event.facts[1].runId) + expect(f.drafts.findById('draft-1')?.revision).toBe(1) + commits.push(event) + }) + f.launch.mockRejectedValueOnce(new Error('Planner unavailable')) + await expect(f.service.execute(f.input)).rejects.toThrow('Planner unavailable') + expect(commits).toHaveLength(1) + expect(commits[0]?.facts).toEqual([ + { kind: 'command.accepted', command: 'compact' }, + { kind: 'run.queued', runId: expect.any(String), purpose: 'conversation.compaction' }, + { kind: 'draft.consumed', draftId: 'draft-1', sourceRevision: 0, committedRevision: 1 }, + ]) + expect(Reflect.set(commits[0]!.facts[2], 'committedRevision', 20)).toBe(false) + await expect(f.service.execute(f.input)).resolves.toMatchObject({ runId: commits[0]!.facts[1].runId, draftReceipt: { committedRevision: 1 } }) + expect(commits).toHaveLength(1) + expect(f.launch).toHaveBeenCalledTimes(1) + expect(f.database.prepare('SELECT COUNT(*) AS count FROM messages').get()).toEqual({ count: 1 }) + await f.service.dispose() + }) + + it('does not publish partial facts when consuming the draft rolls back the transaction', async () => { + const f = fixture() + const commits: ChatCommandCommit[] = [] + f.service.onDidCommit(event => commits.push(event)) + f.database.exec('CREATE TRIGGER reject_draft BEFORE UPDATE ON composer_drafts BEGIN SELECT RAISE(ABORT, \'draft unavailable\'); END') + await expect(f.service.execute(f.input)).rejects.toThrow('draft unavailable') + expect(commits).toEqual([]) + expect(f.commands.findByRequestId(f.input.requestId)).toBeNull() + expect(f.drafts.findById('draft-1')?.revision).toBe(0) + expect(f.runs.listRecent()).toHaveLength(1) + await f.service.dispose() + }) + + it('drains an accepted operation when a commit observer requests shutdown', async () => { + const f = fixture() + const gate = Promise.withResolvers() + f.launch.mockImplementationOnce(async (runId, signal) => { + expect(signal?.aborted).toBe(true) + await gate.promise + return { runId, completion: Promise.resolve(f.runs.findById(runId)!) } + }) + let stopping: Promise | undefined + let stopped = false + f.service.onDidCommit(() => { + stopping = f.service.dispose().then(() => { + stopped = true + }) + }) + const operation = f.service.execute(f.input) + await Promise.resolve() + expect(stopped).toBe(false) + gate.resolve() + await operation + await stopping + expect(stopped).toBe(true) + await expect(f.service.execute(f.input)).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const conversations = createConversationRepository(database) + const now = '2026-09-28T00:00:00.000Z' + conversations.create({ id: 'task-1', branchId: 'branch-1', title: 'Fixture', createdAt: now, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + conversations.createMessage({ id: 'message-1', conversationId: 'task-1', branchId: 'branch-1', runId: null, role: 'user', content: { text: 'Fixture' }, createdAt: now }) + const runs = createRunRepository(database) + runs.create({ id: 'source-run', conversationId: 'task-1', branchId: 'branch-1', triggeringMessageId: 'message-1', provider: 'fixture', model: 'fixture', piSessionFile: '/fixture/session.jsonl', purpose: 'chat', status: 'completed', startedAt: now, completedAt: now, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + const drafts = createComposerDraftRepository(database) + drafts.open({ + draftId: 'draft-1', + scope: { kind: 'conversation_branch', conversationId: 'task-1', branchId: 'branch-1' }, + initialContent: { version: 1, panelResourceIds: [], body: [{ type: 'paragraph', content: [{ type: 'prompt_directive', directive: 'slash_command', commandMode: 'action', value: '/compact' }, { type: 'text', text: ' private summary' }] }] }, + initialExecutionConfig: { approvalPolicy: 'policy', executionProfile: 'workspace_write' }, + initialModelSelection: null, + now, + }) + const commands = createCommandRequestRepository(database) + const launch = vi.fn(async (runId: string, _signal?: AbortSignal) => ({ runId, completion: Promise.resolve(runs.findById(runId)!) })) + const service = new ChatCommandService({ commands, conversations, drafts, runs, spaces: createSpaceRepository(database), conversationLifecycle: { isDeleting: conversations.isDeleted }, turnLauncher: { launch } }) + return { service, commands, drafts, runs, database, launch, input: { requestId: 'compact-1', draftId: 'draft-1', expectedRevision: 0 } } +} diff --git a/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts b/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts index 3d3280e3..f4d36c29 100644 --- a/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts +++ b/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts @@ -1,8 +1,15 @@ import type { DatabaseSync } from 'node:sqlite' import type { BuddyInputReferenceV1 } from '../../agent/context/BuddyInputReference' +import type { ExecutionSettled } from '../../agent/execution/ActiveRunRegistry' +import type { BuddyRunEvent } from '../../events/BuddyRunEvent' +import type { RunEventObservation } from '../../events/RunEventPorts' import type { PrepareTurnRequestInput } from '../../storage/turnRequestRepository' +import type { ChatQueueChange, ChatQueueServiceOptions } from '../ChatQueueService' +import type { TurnRequestCommit } from '../TurnRequestService' import { afterEach, describe, expect, it, vi } from 'vitest' import { createBuddyUserContent } from '../../../../shared/conversation/buddyUserContent' +import { Emitter } from '../../../../shared/events/Emitter' +import { createAttachmentRepository } from '../../storage/attachmentRepository' import { createChatQueueRepository } from '../../storage/chatQueueRepository' import { createComposerDraftRepository } from '../../storage/composerDraftRepository' import { openBuddyDatabase } from '../../storage/database' @@ -10,16 +17,171 @@ import { createRunInputRepository } from '../../storage/runInputRepository' import { createRunRepository } from '../../storage/runRepository' import { createTurnRequestRepository } from '../../storage/turnRequestRepository' import { ChatQueueService } from '../ChatQueueService' +import { QueueContinuation } from '../QueueContinuation' +import { TurnRequestService } from '../TurnRequestService' -const fixtures: Array<{ database: DatabaseSync, service: ChatQueueService }> = [] -afterEach(() => { +const fixtures: Array<{ database: DatabaseSync, service: ChatQueueService, continuation: QueueContinuation }> = [] +afterEach(async () => { for (const fixture of fixtures.splice(0)) { + const stopped = fixture.continuation.dispose() fixture.service.dispose() + await stopped + await fixture.service.drain() fixture.database.close() } }) describe('persistent chat queue scheduling', () => { + it('commits queue and message ownership separately and consumes a queued draft only once', async () => { + const f = fixture() + const attachments = createAttachmentRepository(f.database) + attachments.create({ id: 'owned-attachment', conversationId: null, draftId: 'draft', messageId: null, name: 'fixture.txt', mimeType: 'text/plain', sizeBytes: 1, storedPath: '/private/source.txt', createdAt: '2026-09-15T00:00:00.000Z' }) + const prepared = f.input('A') + prepared.runInput.attachmentIds = ['owned-attachment'] + const binding = { id: 'owned-attachment', sourceAttachmentId: 'owned-attachment', sourceDraftId: 'draft', messageId: prepared.userMessageId, storedPath: '/private/destination.txt', sourceStoredPath: '/private/source.txt', name: 'fixture.txt', sizeBytes: 1, mimeType: 'text/plain', createdAt: prepared.createdAt } + prepared.attachmentBindings = [binding] + f.turns.prepareStart = async () => ({ prepared, stagedAttachments: { validate: () => {}, bindings: [binding], commit: async () => {}, rollback: async () => {} } }) + const events: ChatQueueChange[] = [] + f.service.onDidChange(event => events.push(event)) + const input = { requestId: prepared.requestId, draftId: 'draft', expectedRevision: prepared.draft.expectedRevision } + await f.service.enqueue(input) + await f.service.enqueue(input) + expect(events).toHaveLength(1) + expect(events[0]!.committed).toMatchObject({ attachmentOwnership: { kind: 'queue', attachmentIds: ['owned-attachment'] }, draftReceipt: { sourceRevision: input.expectedRevision } }) + expect(attachments.findById('owned-attachment')).toMatchObject({ draftId: 'A', messageId: null }) + expect(await f.service.followUp('run-initial', f.controller.signal)).toBe(true) + expect(events[1]!.committed).toMatchObject({ messageId: 'A', attachmentOwnership: { kind: 'message', attachmentIds: ['owned-attachment'] } }) + expect(events[1]!.committed?.draftReceipt).toBeUndefined() + expect(events[1]!.committed?.commitId).not.toBe(events[0]!.committed?.commitId) + expect(attachments.findById('owned-attachment')).toMatchObject({ draftId: null, messageId: 'A', conversationId: 'conversation' }) + }) + + it('dispatches a queued message with a new turn batch without repeating draft consumption', async () => { + const f = fixture() + const events: TurnRequestCommit[] = [] + f.requests.onDidCommit(event => events.push(event)) + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.settle('run-initial') + await vi.waitFor(() => expect(events).toHaveLength(1)) + expect(events[0]!.facts.map(fact => fact.kind)).toEqual(['message.created', 'run.queued', 'queue.dispatched']) + }) + + it('keeps persisted waiting work paused when the owner restarts', async () => { + const f = fixture(true) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.releaseSlot() + f.continuation.reconcile() + await f.continuation.start() + expect(f.queue.list(f.scope)[0]?.state).toBe('paused') + expect(f.launched).toEqual([]) + }) + + it('resolves the current queue branch instead of treating a late settlement as a launch target', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.releaseSlot() + f.settled.fire({ runId: 'run-initial', conversationId: 'conversation', branchId: 'retired-branch', executionId: 'old-execution', cleanup: 'completed', stopping: false }) + await vi.waitFor(() => expect(f.launched).toEqual(['run-A'])) + expect(f.runs.findById('run-A')?.branchId).toBe('branch') + }) + + it('does not continue from a durable terminal until the execution slot is released', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.committed.fire({ runId: 'run-initial', type: 'run.completed', sequence: 1, createdAt: new Date().toISOString(), payload: {} }) + await new Promise(resolve => setTimeout(resolve, 15)) + expect(f.launched).toEqual([]) + f.settle('run-initial') + f.settle('run-initial') + await vi.waitFor(() => expect(f.launched).toEqual(['run-A'])) + expect(f.database.prepare('SELECT id FROM messages WHERE id = ?').all('A')).toHaveLength(1) + }) + + it('recovers a missed wake by reconciling current state', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.releaseSlot() + f.continuation.reconcile() + await vi.waitFor(() => expect(f.launched).toEqual(['run-A'])) + }) + + it('retains cleanup degradation when its settlement notification was missed', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.releaseSlot('degraded') + f.continuation.reconcile() + await vi.waitFor(() => expect(f.queue.list(f.scope)[0]?.state).toBe('paused')) + expect(f.launched).toEqual([]) + }) + + it('bounds failed reconciliation and recovers without silently resuming the paused queue', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.releaseSlot() + const unavailable = vi.spyOn(f.queue, 'list').mockImplementation(() => { + throw new Error('Storage unavailable') + }) + f.continuation.reconcile() + await vi.waitFor(() => expect(unavailable).toHaveBeenCalledTimes(3)) + expect(f.continuation.state).toBe('degraded') + expect(f.launched).toEqual([]) + unavailable.mockRestore() + expect(f.queue.list(f.scope)[0]?.state).toBe('paused') + f.continuation.reconcile() + await vi.waitFor(() => expect(f.continuation.state).toBe('ready')) + expect(f.launched).toEqual([]) + expect(await f.service.steer(f.target('A'))).toBe(true) + expect(f.launched).toEqual(['run-A']) + }) + + it('keeps a completed run paused when its execution cleanup degraded', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + f.settle('run-initial', 'degraded') + await vi.waitFor(() => expect(f.queue.list(f.scope)[0]?.state).toBe('paused')) + f.continuation.reconcile() + await new Promise(resolve => setTimeout(resolve, 15)) + expect(f.launched).toEqual([]) + }) + + it('does not start a new run when disposed during continuation validation', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + const gate = Promise.withResolvers() + f.validate.mockImplementationOnce(() => gate.promise) + f.settle('run-initial') + await vi.waitFor(() => expect(f.validate).toHaveBeenCalledOnce()) + const stopped = f.continuation.dispose() + f.service.dispose() + gate.resolve() + await stopped + expect(f.launched).toEqual([]) + expect(f.database.prepare('SELECT id FROM runs').all()).toEqual([{ id: 'run-initial' }]) + }) + + it('rechecks durable storage health after asynchronous validation', async () => { + const f = fixture() + f.queue.enqueue(f.input('A')) + f.database.exec('UPDATE runs SET status = \'completed\'') + const gate = Promise.withResolvers() + f.validate.mockImplementationOnce(() => gate.promise) + f.settle('run-initial') + await vi.waitFor(() => expect(f.validate).toHaveBeenCalledOnce()) + f.eventLog.state = 'failed' + gate.resolve() + await f.service.drain() + expect(f.launched).toEqual([]) + expect(f.database.prepare('SELECT id FROM runs').all()).toEqual([{ id: 'run-initial' }]) + }) + it('steers only B and lazily follows up A then C within the same run', async () => { const f = fixture() for (const id of ['A', 'B', 'C']) @@ -49,7 +211,7 @@ describe('persistent chat queue scheduling', () => { const steering = f.service.steer(f.target('B')) await vi.waitFor(() => expect(f.validate).toHaveBeenCalledOnce()) f.database.exec('UPDATE runs SET status = \'completed\'') - f.service.onRunSettled('run-initial') + f.settle('run-initial') await new Promise(resolve => setTimeout(resolve, 10)) gate.resolve() expect(await steering).toBe(false) @@ -120,7 +282,7 @@ describe('persistent chat queue scheduling', () => { expect(await f.service.followUp('run-initial', f.controller.signal)).toBe(false) expect(f.queue.list(f.scope).map(item => item.id)).toEqual(['A', 'C']) f.database.exec('UPDATE runs SET status = \'completed\'') - f.service.onRunSettled('run-initial') + f.settle('run-initial') await vi.waitFor(() => expect(f.launched).toEqual(['run-A'])) expect(f.runInputs.findByRunId('run-A')).toMatchObject(input.runInput) expect(f.runs.findById('run-A')).toMatchObject({ model: input.model, provider: input.provider, executionProfile: input.executionProfile, approvalPolicy: input.approvalPolicy }) @@ -137,7 +299,7 @@ describe('persistent chat queue scheduling', () => { expect(f.queue.pending(f.target('review'))?.runExecutionProfile).toBe('read_only') expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) f.database.exec('UPDATE runs SET status = \'completed\'') - f.service.onRunSettled('run-initial') + f.settle('run-initial') await vi.waitFor(() => expect(f.runs.findById('run-review')?.executionProfile).toBe('read_only')) expect(f.queue.list(f.scope)).toEqual([]) expect(f.database.prepare('SELECT execution_profile FROM conversations').get()).toEqual({ execution_profile: 'workspace_write' }) @@ -180,13 +342,13 @@ describe('persistent chat queue scheduling', () => { expect(f.queue.pending(f.target('A'))).not.toBeNull() expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) f.database.exec('UPDATE runs SET status = \'completed\'') - f.service.onRunSettled('run-initial') + f.settle('run-initial') await vi.waitFor(() => expect(f.launched).toEqual(['run-A'])) expect(f.database.prepare('SELECT id FROM messages ORDER BY rowid').all()).toEqual([{ id: 'initial' }, { id: 'A' }]) }) }) -function fixture() { +function fixture(queuedOnStartup = false) { const database = openBuddyDatabase({ databasePath: ':memory:' }) const drafts = createComposerDraftRepository(database) drafts.open({ draftId: 'draft', scope: { kind: 'global' }, initialContent: createBuddyUserContent('initial'), initialModelSelection: null, initialExecutionConfig: { approvalPolicy: 'policy', executionProfile: 'workspace_write' }, now: '2026-09-15T00:00:00.000Z' }) @@ -208,33 +370,56 @@ function fixture() { userMessageContent: { userContent: createBuddyUserContent(id), resourceSnapshots: [] }, userMessageId: id, }) - const requests = createTurnRequestRepository(database) + const requests = new TurnRequestService(createTurnRequestRepository(database)) requests.prepare(input('initial')) database.exec('UPDATE runs SET status = \'running\'') const queue = createChatQueueRepository(database) const runInputs = createRunInputRepository(database) const runs = createRunRepository(database) + if (queuedOnStartup) + queue.enqueue(input('A')) const delivered: string[] = [] const deliver = (mode: string) => (_runId: string, prepare: () => BuddyInputReferenceV1) => { delivered.push(`${mode}:${prepare().messageId}`) return true } - const runner = { steer: vi.fn(deliver('steer')), followUp: vi.fn(deliver('followUp')) } + const settled = new Emitter(() => {}) + let occupied = true + let cleanupDegraded = false + const runner = { onDidSettle: settled.event, hasActiveExecution: () => occupied, hasDegradedCleanup: () => cleanupDegraded, isStopping: false, steer: vi.fn(deliver('steer')), followUp: vi.fn(deliver('followUp')) } const validate = vi.fn(async (_input: PrepareTurnRequestInput) => {}) const launched: string[] = [] + const committed = new Emitter(() => {}) + const eventLog: { onDidCommit: RunEventObservation['onDidCommit'], state: RunEventObservation['state'] } = { onDidCommit: committed.event, state: 'open' } + const turns: ChatQueueServiceOptions['turns'] = { + prepareStart: async () => { + throw new Error('Use the persisted fixture') + }, + validatePreparedInput: validate, + } const service = new ChatQueueService({ queue, + eventLog, requests, runs, runInputs, runner, - turns: { prepareStart: async () => { throw new Error('Use the persisted fixture') }, validatePreparedInput: validate }, + turns, launcher: { launch: async (runId) => { launched.push(runId) return { runId, completion: new Promise(() => {}) } } }, }) - fixtures.push({ database, service }) + const continuation = new QueueContinuation({ queue: service, runner, runs, eventLog }) + const settle = (runId: string, cleanup: ExecutionSettled['cleanup'] = 'completed') => { + occupied = false + cleanupDegraded = cleanup === 'degraded' + settled.fire({ runId, conversationId: 'conversation', branchId: 'branch', executionId: runId, cleanup, stopping: false }) + } + fixtures.push({ database, service, continuation }) const scope = { conversationId: 'conversation', branchId: 'branch' } - return { database, drafts, queue, input, service, scope, target: (id: string) => ({ ...scope, id }), controller: new AbortController(), validate, delivered, launched, runInputs, runs, runner } + return { database, drafts, queue, input, service, continuation, settled, settle, committed, eventLog, releaseSlot: (cleanup: ExecutionSettled['cleanup'] = 'completed') => { + occupied = false + cleanupDegraded = cleanup === 'degraded' + }, scope, target: (id: string) => ({ ...scope, id }), controller: new AbortController(), validate, delivered, launched, runInputs, runs, runner, requests, turns } } diff --git a/apps/buddy/service/src/chat/__tests__/ComposerDraftService.spec.ts b/apps/buddy/service/src/chat/__tests__/ComposerDraftService.spec.ts index e085dab0..2c11263f 100644 --- a/apps/buddy/service/src/chat/__tests__/ComposerDraftService.spec.ts +++ b/apps/buddy/service/src/chat/__tests__/ComposerDraftService.spec.ts @@ -1,3 +1,4 @@ +import type { ComposerDraftCleanup, ComposerDraftCommit } from '../ComposerDraftService' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -22,6 +23,40 @@ const input = { } describe('composerDraftService lifecycle', () => { + it('publishes canonical creation once and keeps discard commit separate from retryable cleanup', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const drafts = createComposerDraftRepository(database) + let failed = false + const service = new ComposerDraftService(drafts, async () => { + if (!failed) { + failed = true + throw new Error('Fixture cleanup failure') + } + }) + const commits: ComposerDraftCommit[] = [] + const cleanup: ComposerDraftCleanup[] = [] + service.onDidCommit(event => commits.push(event)) + service.onDidCleanup(event => cleanup.push(event)) + try { + const opened = await service.open({ ...input, scope: { kind: 'global' } }) + const reused = await service.open({ ...input, draftId: 'proposed-alias', scope: { kind: 'global' } }) + expect(reused.draftId).toBe(opened.draftId) + expect(commits.map(event => event.draftId)).toEqual([input.draftId]) + await service.open({ ...input, draftId: 'discard-me', scope: { kind: 'task', draftId: 'discard-me', spaceId: null } }) + await expect(service.discard({ draftId: 'discard-me', expectedRevision: 0 })).rejects.toThrow('Fixture cleanup failure') + expect(drafts.findById('discard-me')).toBeNull() + expect(commits.map(event => event.kind)).toEqual(['opened', 'opened', 'discarded']) + await service.discard({ draftId: 'discard-me', expectedRevision: 0 }) + expect(commits).toHaveLength(3) + expect(cleanup.map(event => event.status)).toEqual(['started', 'failed', 'started', 'completed']) + expect(Reflect.set(commits[0]!.scope, 'kind', 'task')).toBe(false) + } + finally { + await service.dispose() + database.close() + } + }) + it('waits for imports, removes only owned copies, and rejects late writes after discard', async () => { const root = await mkdtemp(join(tmpdir(), 'lexora-input-discard-')) const database = openBuddyDatabase({ databasePath: ':memory:' }) diff --git a/apps/buddy/service/src/chat/__tests__/persistPreparedTurn.spec.ts b/apps/buddy/service/src/chat/__tests__/persistPreparedTurn.spec.ts index bd9aaf53..2fdcce8b 100644 --- a/apps/buddy/service/src/chat/__tests__/persistPreparedTurn.spec.ts +++ b/apps/buddy/service/src/chat/__tests__/persistPreparedTurn.spec.ts @@ -1,5 +1,5 @@ -import type { PreparedMessageAttachments } from '../../attachments/AttachmentService' import type { TurnRequestRecord } from '../../storage/turnRequestRepository' +import type { PreparedTurnAttachments } from '../persistPreparedTurn' import { describe, expect, it } from 'vitest' import { persistPreparedTurn } from '../persistPreparedTurn' @@ -39,9 +39,10 @@ describe('persistPreparedTurn', () => { function attachments(options: { commit: () => void rollback: () => void -}): PreparedMessageAttachments { +}): PreparedTurnAttachments { return { bindings: [], + validate: () => {}, commit: async () => options.commit(), rollback: async () => options.rollback(), } diff --git a/apps/buddy/service/src/chat/persistPreparedTurn.ts b/apps/buddy/service/src/chat/persistPreparedTurn.ts index 2746ea7f..fa51a517 100644 --- a/apps/buddy/service/src/chat/persistPreparedTurn.ts +++ b/apps/buddy/service/src/chat/persistPreparedTurn.ts @@ -1,12 +1,33 @@ import type { PreparedMessageAttachments } from '../attachments/AttachmentService' +import type { PreparedComposerInput } from '../attachments/ComposerResourceService' import type { TurnRequestRecord } from '../storage/turnRequestRepository' +export interface PreparedTurnAttachments extends PreparedMessageAttachments { + validate: () => void +} + +export function combinePreparedAttachments(attachments: PreparedMessageAttachments, resources: PreparedComposerInput): PreparedTurnAttachments { + const settle = async (outcome: 'commit' | 'rollback') => { + const failures: unknown[] = [] + for (const receipt of [attachments, resources]) { + try { + await receipt[outcome]() + } + catch (error) { failures.push(error) } + } + if (failures.length) + throw new AggregateError(failures, 'TURN_ATTACHMENT_CLEANUP_FAILED') + } + return Object.freeze({ bindings: attachments.bindings, validate: resources.validate, commit: () => settle('commit'), rollback: () => settle('rollback') }) +} + export async function persistPreparedTurn( - attachments: PreparedMessageAttachments | null, + attachments: PreparedTurnAttachments | null, persist: () => TurnRequestRecord, ): Promise { let prepared: TurnRequestRecord try { + attachments?.validate() prepared = persist() } catch (error) { diff --git a/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts b/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts index 5c3e6860..78575d4b 100644 --- a/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts +++ b/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts @@ -1,16 +1,21 @@ import type { CallToolResult, OAuthClientProvider, Progress } from '@modelcontextprotocol/client' import type { ConnectorCredential } from '../../../../shared/connectors/connectorCredentials' import type { ConnectorErrorCode, ConnectorRuntimeState } from '../../../../shared/connectors/connectorState' +import type { Event, ListenerErrorHandler } from '../../../../shared/events/Emitter' import type { ConnectorRepository, McpServerRecord } from '../../storage/connectorRepository' -import type { McpRemoteTool } from './McpClientSession' +import type { McpCatalogTool, McpConnectionDetails, McpConnectionEvent } from './mcpEvents' import type { McpServerConfig } from './mcpSchemas' +import { createHash, randomUUID } from 'node:crypto' +import { Emitter } from '../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { mcpToolFingerprint, parseMcpCatalog, readMcpCatalog } from './mcpCatalog' import { McpClientSession } from './McpClientSession' import { McpClientError, mcpErrorCode } from './mcpErrors' import { waitForMcpOperation } from './waitForMcpOperation' interface Catalog { - tools: McpRemoteTool[] + tools: readonly McpCatalogTool[] + revision: string updatedAt: string } @@ -25,8 +30,7 @@ export interface McpConnectionManagerOptions { readCredential: (id: string) => Promise authProvider: (record: McpServerRecord, credential: ConnectorCredential | null, signal: AbortSignal) => OAuthClientProvider | undefined config: (record: McpServerRecord) => McpServerConfig - changed: () => Promise | unknown - unavailable: (id: string, code: ConnectorErrorCode) => void + onListenerError?: ListenerErrorHandler maxReconnectAttempts?: number } @@ -40,21 +44,32 @@ export class McpConnectionManager { readonly #authorizations = new Map() readonly #refreshes = new Map>() readonly #background = new Set>() + readonly #events: Emitter + readonly sourceId = randomUUID() + #revision = 0 #closed = false constructor(options: McpConnectionManagerOptions) { this.#options = options + this.#events = new Emitter(options.onListenerError ?? (() => console.error('MCP_CONNECTION_OBSERVER_FAILED'))) for (const record of options.repository.list()) { const catalog = options.repository.readCatalog(record.id) if (!catalog) continue try { - this.#catalogs.set(record.id, { tools: readMcpCatalog(catalog.toolsJson), updatedAt: catalog.updatedAt }) + const tools = copyEventSnapshot(readMcpCatalog(catalog.toolsJson)) + this.#catalogs.set(record.id, { tools, revision: catalogRevision(tools), updatedAt: catalog.updatedAt }) } catch { options.repository.clearCatalog(record.id) } } } + readonly onDidChange: Event = (listener, options) => this.#events.event(listener, options) + + catalogRevision(id: string): string { + return this.#catalogs.get(id)?.revision ?? catalogRevision([]) + } + start(): void { const ids = this.#options.repository.list().filter(record => record.enabled).map(record => record.id) this.#track(Promise.all(Array.from({ length: Math.min(ids.length, 3) }, async () => { @@ -65,12 +80,24 @@ export class McpConnectionManager { }))) } - pause(id: string): void { this.#paused.add(id) } - resume(id: string): void { this.#paused.delete(id) } + pause(id: string): void { + const available = this.available(id, this.generation(id)) + this.#paused.add(id) + if (available) + this.#publish(id, { type: 'availability', available: false }) + } + + resume(id: string): void { + if (this.#paused.delete(id) && this.available(id, this.generation(id))) + this.#publish(id, { type: 'availability', available: true }) + } clearCatalog(id: string): void { - this.#catalogs.delete(id) + const previous = this.#catalogs.get(id) this.#options.repository.clearCatalog(id) + this.#catalogs.delete(id) + if (previous) + this.#publish(id, { type: 'catalog', catalogRevision: catalogRevision([]), previousRevision: previous.revision, tools: [] }) } warm(id: string): void { @@ -82,25 +109,31 @@ export class McpConnectionManager { return this.#generations.get(id) ?? 0 } - catalog(id: string): readonly McpRemoteTool[] { - return this.#catalogs.get(id)?.tools ?? [] + catalog(id: string): readonly McpCatalogTool[] { + return this.#catalogs.get(id)?.tools ?? Object.freeze([]) } state(id: string): ConnectorRuntimeState { const record = this.#options.repository.findById(id) const transient = this.#states.get(id) - const status = transient?.status === 'connecting' || transient?.status === 'authenticating' - ? transient.status - : record?.enabled ? transient?.status ?? 'idle' : 'disabled' - return { status, authorization: this.#authorizations.get(id) ?? null, errorCode: transient?.errorCode ?? null, toolCount: this.catalog(id).length, updatedAt: this.#catalogs.get(id)?.updatedAt ?? null } + const status = this.#closed + ? 'disabled' + : transient?.status === 'connecting' || transient?.status === 'authenticating' + ? transient.status + : record?.enabled ? transient?.status ?? 'idle' : 'disabled' + return Object.freeze({ status, authorization: this.#authorizations.get(id) ?? null, errorCode: transient?.errorCode ?? null, toolCount: this.catalog(id).length, updatedAt: this.#catalogs.get(id)?.updatedAt ?? null }) } setState(id: string, status: ConnectorRuntimeState['status'], errorCode: ConnectorErrorCode | null = null) { + const previous = this.state(id) this.#states.set(id, { status, errorCode }) + this.#publishState(id, previous) } setAuthorization(id: string, authorization: ConnectorRuntimeState['authorization']) { + const previous = this.state(id) this.#authorizations.set(id, authorization) + this.#publishState(id, previous) } available(id: string, generation: number): boolean { @@ -110,19 +143,27 @@ export class McpConnectionManager { } async reset(id: string, clearCatalog = false): Promise { - this.#generations.set(id, this.generation(id) + 1) + const previousGeneration = this.generation(id) + this.#generations.set(id, previousGeneration + 1) this.#states.delete(id) this.#authorizations.delete(id) const connection = this.#connections.get(id) this.#connections.delete(id) connection?.controller.abort(new McpClientError('MCP_CONNECTOR_CHANGED')) - if (clearCatalog) { - this.#catalogs.delete(id) - this.#options.repository.clearCatalog(id) + this.#publish(id, { type: 'generation', previousGeneration }) + if (clearCatalog) + this.clearCatalog(id) + try { + if (connection) + await connection.session.then(session => session.close(), () => {}) + await this.#refreshes.get(id)?.catch(() => {}) + if (connection) + this.#publish(id, { type: 'cleanup', status: 'completed' }) + } + catch (error) { + this.#publish(id, { type: 'cleanup', status: 'degraded' }) + throw error } - if (connection) - await connection.session.then(session => session.close(), () => {}) - await this.#refreshes.get(id)?.catch(() => {}) } refresh(id: string): Promise { @@ -145,24 +186,30 @@ export class McpConnectionManager { try { const connection = this.#connection(record) const session = await connection.session - const tools = parseMcpCatalog(await session.listTools(connection.controller.signal)) + const tools = copyEventSnapshot(parseMcpCatalog(await session.listTools(connection.controller.signal))) connection.controller.signal.throwIfAborted() if (generation !== this.generation(id)) return const previous = this.#catalogs.get(id) const updatedAt = new Date().toISOString() this.#options.repository.saveCatalog(id, JSON.stringify(tools), updatedAt) - this.#catalogs.set(id, { tools, updatedAt }) - this.setState(id, 'ready') - if (JSON.stringify(previous?.tools) !== JSON.stringify(tools)) - await this.#options.changed() + const revision = catalogRevision(tools) + const previousState = this.state(id) + this.#catalogs.set(id, { tools, revision, updatedAt }) + this.#states.set(id, { status: 'ready', errorCode: null }) + const snapshot = this.state(id) + const events: McpConnectionEvent[] = [] + if (JSON.stringify(previousState) !== JSON.stringify(snapshot)) + events.push(this.#event(id, { type: 'state', snapshot })) + if (previous?.revision !== revision) + events.push(this.#event(id, { type: 'catalog', catalogRevision: revision, previousRevision: previous?.revision ?? null, tools })) + this.#events.fireBatch(events) } catch (error) { if (generation !== this.generation(id) || this.#closed) return const code = mcpErrorCode(error) this.setState(id, code === 'MCP_AUTHENTICATION_REQUIRED' ? 'needs_auth' : 'error', code) - this.#options.unavailable(id, code) throw error } finally { @@ -177,11 +224,10 @@ export class McpConnectionManager { async reconnect(id: string): Promise { this.#requireRecord(id) await this.reset(id) - await this.#options.changed() return this.refresh(id) } - async callTool(id: string, generation: number, tool: McpRemoteTool, parameters: unknown, signal?: AbortSignal, onProgress?: (progress: Progress) => void): Promise { + async callTool(id: string, generation: number, tool: McpCatalogTool, parameters: unknown, signal?: AbortSignal, onProgress?: (progress: Progress) => void): Promise { signal?.throwIfAborted() this.#assertAvailable(id, generation, tool) await waitForMcpOperation(this.refresh(id), signal) @@ -206,14 +252,33 @@ export class McpConnectionManager { } } - async close(): Promise { + async quiesce(): Promise { + const states = new Map([...this.#connections.keys()].map(id => [id, this.state(id)])) this.#closed = true - const connections = [...this.#connections.values()] - this.#connections.clear() - for (const connection of connections) + for (const [id, connection] of this.#connections) { connection.controller.abort() - const results = await Promise.allSettled(connections.map(connection => connection.session.then(session => session.close(), () => {}))) + this.#publishState(id, states.get(id)!) + } + while (this.#refreshes.size || this.#background.size) + await Promise.allSettled([...this.#refreshes.values(), ...this.#background]) + } + + async close(): Promise { + await this.quiesce() + const connections = [...this.#connections] + this.#connections.clear() + const results = await Promise.allSettled(connections.map(async ([id, connection]) => { + try { + await connection.session.then(session => session.close(), () => {}) + this.#publish(id, { type: 'cleanup', status: 'completed' }) + } + catch (error) { + this.#publish(id, { type: 'cleanup', status: 'degraded' }) + throw error + } + })) await Promise.allSettled([...this.#refreshes.values(), ...this.#background]) + this.#events.dispose() const failures = results.filter(result => result.status === 'rejected').map(result => result.reason) if (failures.length) throw new AggregateError(failures, 'MCP shutdown failed') @@ -232,14 +297,14 @@ export class McpConnectionManager { const credential = record.credentialRef ? await this.#options.readCredential(record.credentialRef) : null controller.signal.throwIfAborted() if (credential?.type === 'oauth') - this.#authorizations.set(record.id, 'oauth') + this.setAuthorization(record.id, 'oauth') return new McpClientSession({ config: this.#options.config(record), credential, authProvider: this.#options.authProvider(record, credential, controller.signal), onAuthorization: (kind) => { if (generation === this.generation(record.id) && !controller.signal.aborted) - this.#authorizations.set(record.id, kind) + this.setAuthorization(record.id, kind) }, maxReconnectAttempts: this.#options.maxReconnectAttempts, onToolsChanged: () => { @@ -249,7 +314,6 @@ export class McpConnectionManager { onUnavailable: (code) => { if (generation === this.generation(record.id) && !controller.signal.aborted) { this.setState(record.id, 'error', code) - this.#options.unavailable(record.id, code) } }, }) @@ -259,7 +323,7 @@ export class McpConnectionManager { return connection } - #assertAvailable(id: string, generation: number, tool: McpRemoteTool): void { + #assertAvailable(id: string, generation: number, tool: McpCatalogTool): void { if (!this.available(id, generation)) throw new McpClientError('MCP_CONNECTOR_DISABLED') const current = this.catalog(id).find(candidate => candidate.name === tool.name) @@ -276,8 +340,26 @@ export class McpConnectionManager { return record } + #publishState(id: string, previous: ConnectorRuntimeState): void { + const snapshot = this.state(id) + if (JSON.stringify(previous) !== JSON.stringify(snapshot)) + this.#publish(id, { type: 'state', snapshot }) + } + + #publish(id: string, details: McpConnectionDetails): void { + this.#events.fire(this.#event(id, details)) + } + + #event(id: string, details: McpConnectionDetails): McpConnectionEvent { + return copyEventSnapshot({ ...details, sourceId: this.sourceId, revision: ++this.#revision, connectorId: id, generation: this.generation(id) }) + } + #track(operation: Promise) { this.#background.add(operation) void operation.finally(() => this.#background.delete(operation)).catch(() => {}) } } + +function catalogRevision(tools: readonly McpCatalogTool[]): string { + return createHash('sha256').update(JSON.stringify(tools)).digest('hex') +} diff --git a/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts b/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts index dd5c737f..21fe539d 100644 --- a/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts +++ b/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts @@ -1,12 +1,18 @@ import type { ToolDefinition } from '@earendil-works/pi-coding-agent' import type { ConnectorCredential, OAuthConnectorCredential } from '../../../../shared/connectors/connectorCredentials' -import type { ConnectorErrorCode, ConnectorRuntimeState, ConnectorToolSummary } from '../../../../shared/connectors/connectorState' +import type { ConnectorRuntimeState, ConnectorToolSummary } from '../../../../shared/connectors/connectorState' +import type { Event, ListenerErrorHandler } from '../../../../shared/events/Emitter' import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import type { BuddyCapabilityResourceRevision } from '../../agent/extensions/BuddyCapability' import type { BuddyToolClassification } from '../../approvals/toolClassification' import type { ConnectorRepository, McpServerRecord } from '../../storage/connectorRepository' +import type { McpConnectionEvent, McpConnectorDetails, McpConnectorEvent } from './mcpEvents' import type { McpServerConfig } from './mcpSchemas' import type { McpResultWriter } from './mcpToolResults' +import { createHash, randomUUID } from 'node:crypto' import { connectorCredentialSchema } from '../../../../shared/connectors/connectorCredentials' +import { Emitter } from '../../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { credentialMutationResultSchema, credentialReadResultSchema } from '../../../../shared/runtime/credentialProtocol' import { createMcpTools } from './createMcpTools' import { McpConnectionManager } from './McpConnectionManager' @@ -21,22 +27,16 @@ export interface ConnectorSecretStore { write: (id: string, credential: ConnectorCredential) => Promise } -export interface BuddyConnectorEvent { - code?: ConnectorErrorCode - connectorId: string - type: 'connector.tools_changed' | 'connector.unavailable' -} - export interface McpConnectorServiceOptions { connectors: ConnectorRepository - invalidateSessions?: () => Promise | unknown maxReconnectAttempts?: number - notify?: (event: BuddyConnectorEvent) => void + onListenerError?: ListenerErrorHandler secrets: ConnectorSecretStore openExternal?: (url: string) => Promise } export interface BuddyMcpTools { + readonly resourceRevisions?: readonly BuddyCapabilityResourceRevision[] classifications: Map diagnostics: Array<{ code: string, message: string }> tools: ToolDefinition[] @@ -60,11 +60,17 @@ export class McpConnectorService { readonly #manager: McpConnectionManager readonly #mutations = new Map>() readonly #secretWrites = new Map>() - readonly #logins = new Map }>() + readonly #logins = new Map, operationId: string }>() + readonly #events: Emitter + readonly sourceId = randomUUID() + #revision = 0 #closed = false + #quiescing = false + readonly #tests = new Set>() constructor(options: McpConnectorServiceOptions) { this.#options = options + this.#events = new Emitter(options.onListenerError ?? (() => console.error('MCP_CONNECTOR_OBSERVER_FAILED'))) this.#connectors = options.connectors this.#secrets = options.secrets this.#manager = new McpConnectionManager({ @@ -77,14 +83,18 @@ export class McpConnectorService { ? new McpOAuthProvider({ credential, signal, save: value => this.#saveOAuth(record, generation, signal, value) }) : undefined }, - changed: () => options.invalidateSessions?.(), - unavailable: (connectorId, code) => options.notify?.({ connectorId, code, type: 'connector.unavailable' }), + onListenerError: options.onListenerError, maxReconnectAttempts: options.maxReconnectAttempts, }) } + readonly onDidChange: Event = (listener, options) => this.#events.event(listener, options) + readonly onDidChangeConnection: Event = (listener, options) => this.#manager.onDidChange(listener, options) + start(): void { this.#manager.start() } async prepareForRun(signal: AbortSignal): Promise { + if (this.#quiescing) + throw new McpClientError('MCP_SERVER_UNAVAILABLE') signal.throwIfAborted() const pending = this.list().filter(record => this.#manager.available(record.id, this.#manager.generation(record.id)) && this.state(record.id).updatedAt === null).map(record => this.#manager.refresh(record.id)) await waitForMcpOperation(Promise.allSettled(pending), signal) @@ -106,6 +116,7 @@ export class McpConnectorService { const parsed = mcpServerConfigSchema.safeParse(input.config) if (!parsed.success) throw new McpConnectorError('VALIDATION_FAILED') + input = { config: parsed.data, credential: copyEventSnapshot(input.credential) } return this.#mutate(parsed.data.id, async () => { const existing = this.#connectors.findById(parsed.data.id) const previousRef = existing?.credentialRef ?? null @@ -118,20 +129,20 @@ export class McpConnectorService { let record: McpServerRecord try { if (input.credential.mode === 'replace') - await this.#secrets.write(credentialRef!, input.credential.value) + await this.#writeCredential(credentialRef!, input.credential.value, parsed.data.id) else if (input.credential.mode === 'clear' && previousRef) - await this.#secrets.delete(previousRef) + await this.#deleteCredential(previousRef, parsed.data.id) record = this.#persistConfig({ ...parsed.data, credentialRef }, input.credential.mode !== 'keep') } catch (error) { if (input.credential.mode !== 'keep') - await this.#restoreSecret(credentialRef ?? previousRef ?? parsed.data.id, previous) + await this.#restoreSecret(credentialRef ?? previousRef ?? parsed.data.id, previous, parsed.data.id) throw error } if (!existing || !sameExecutionTarget(existing, parsed.data) || input.credential.mode !== 'keep') this.#manager.clearCatalog(record.id) return record - }) + }, () => input.credential.mode !== 'keep' || !sameConfiguration(this.#connectors.findById(parsed.data.id), parsed.data)) } confirmExecution(id: string): Promise { @@ -139,16 +150,21 @@ export class McpConnectorService { const record = this.#requireConnector(id) if (record.transport !== 'stdio') throw new McpConnectorError('VALIDATION_FAILED') + if (record.executionConfirmedAt) + return record const now = new Date().toISOString() - return this.#connectors.upsert({ ...record, executionConfirmedAt: now, updatedAt: now }) - }) + const saved = this.#connectors.upsert({ ...record, executionConfirmedAt: now, updatedAt: now }) + this.#configurationCommitted(record, saved) + return saved + }, () => !this.#connectors.findById(id)?.executionConfirmedAt) } setEnabled(id: string, enabled: boolean): Promise { - return this.#mutate(id, async () => this.#persistConfig(toConfig({ ...this.#requireConnector(id), enabled }))) + return this.#mutate(id, async () => this.#persistConfig(toConfig({ ...this.#requireConnector(id), enabled })), () => this.#connectors.findById(id)?.enabled !== enabled) } async saveCredential(id: string, credential: ConnectorCredential): Promise { + credential = copyEventSnapshot(credential) await this.#mutate(id, async () => { const record = this.#requireConnector(id) const parsed = connectorCredentialSchema.safeParse(credential) @@ -157,11 +173,11 @@ export class McpConnectorService { const credentialRef = record.credentialRef ?? record.id const previous = await this.#secrets.read(credentialRef) try { - await this.#secrets.write(credentialRef, parsed.data) + await this.#writeCredential(credentialRef, parsed.data, id) this.#persistConfig(toConfig({ ...record, credentialRef, enabled: record.transport === 'stdio' ? false : record.enabled }), true) } catch (error) { - await this.#restoreSecret(credentialRef, previous) + await this.#restoreSecret(credentialRef, previous, id) throw error } this.#manager.clearCatalog(id) @@ -175,15 +191,15 @@ export class McpConnectorService { return const previous = await this.#secrets.read(record.credentialRef) try { - await this.#secrets.delete(record.credentialRef) + await this.#deleteCredential(record.credentialRef, id) this.#persistConfig(toConfig({ ...record, credentialRef: null, enabled: record.transport === 'stdio' ? false : record.enabled }), true) } catch (error) { - await this.#restoreSecret(record.credentialRef, previous) + await this.#restoreSecret(record.credentialRef, previous, id) throw error } this.#manager.clearCatalog(id) - }) + }, () => this.#connectors.findById(id)?.credentialRef != null) } remove(id: string): Promise { @@ -193,40 +209,54 @@ export class McpConnectorService { return false const previous = record.credentialRef ? await this.#secrets.read(record.credentialRef) : null if (record.credentialRef) - await this.#secrets.delete(record.credentialRef) + await this.#deleteCredential(record.credentialRef, id) + let removed: boolean try { - const removed = this.#connectors.remove(id) - if (!removed && record.credentialRef) - await this.#restoreSecret(record.credentialRef, previous) - if (removed) - this.#manager.clearCatalog(id) - return removed + removed = this.#connectors.remove(id) } catch (error) { if (record.credentialRef) - await this.#restoreSecret(record.credentialRef, previous) + await this.#restoreSecret(record.credentialRef, previous, id) throw error } - }) + if (!removed && record.credentialRef) + await this.#restoreSecret(record.credentialRef, previous, id) + if (removed) { + this.#publish(id, { type: 'configuration', kind: 'removed', enabled: false, executionChanged: true }) + this.#manager.clearCatalog(id) + } + return removed + }, () => this.#connectors.findById(id) !== null) + } + + test(id: string): Promise { + if (this.#quiescing) + return Promise.reject(new McpClientError('MCP_SERVER_UNAVAILABLE')) + const pending = this.#test(id).finally(() => this.#tests.delete(pending)) + this.#tests.add(pending) + return pending } - async test(id: string): Promise { + async #test(id: string): Promise { this.#requireConnector(id) const login = this.#logins.get(id) this.cancelLogin(id) await login?.operation try { await this.#manager.reconnect(id) - return { ...this.state(id), status: 'ready' } + return Object.freeze({ ...this.state(id), status: 'ready' }) } catch (error) { const code = mcpErrorCode(error) - this.#manager.setState(id, code === 'MCP_AUTHENTICATION_REQUIRED' ? 'needs_auth' : 'error', code) - return { ...this.state(id), status: code === 'MCP_AUTHENTICATION_REQUIRED' ? 'needs_auth' : 'error' } + const status = code === 'MCP_AUTHENTICATION_REQUIRED' ? 'needs_auth' : 'error' + this.#manager.setState(id, status, code) + return Object.freeze({ ...this.state(id), status }) } } login(id: string): void { + if (this.#quiescing) + throw new McpClientError('MCP_SERVER_UNAVAILABLE') const record = this.#requireConnector(id) if (record.transport !== 'streamable-http' || !record.url || !this.#options.openExternal) throw new McpConnectorError('VALIDATION_FAILED') @@ -237,6 +267,7 @@ export class McpConnectorService { const previous = this.#logins.get(id) this.cancelLogin(id) const controller = new AbortController() + const operationId = randomUUID() const operation = Promise.resolve().then(async () => { await previous?.operation if (controller.signal.aborted) @@ -246,10 +277,10 @@ export class McpConnectorService { try { await this.#manager.reset(id) generation = this.#manager.generation(id) - await this.#options.invalidateSessions?.() controller.signal.throwIfAborted() this.#manager.setAuthorization(id, 'oauth') this.#manager.setState(id, 'authenticating') + this.#publish(id, { type: 'login', operationId, status: 'started' }) const credential = await loginMcpOAuth({ url: record.url!, signal: controller.signal, openExternal: this.#options.openExternal! }) if (credential) await this.#saveOAuth(record, generation, controller.signal, credential) @@ -257,10 +288,12 @@ export class McpConnectorService { this.#manager.clearCatalog(id) this.#manager.resume(id) await this.#manager.reconnect(id) + this.#publish(id, { type: 'login', operationId, status: 'succeeded' }) } catch (error) { + const code = controller.signal.aborted ? 'MCP_AUTHENTICATION_CANCELLED' : mcpErrorCode(error, 'MCP_AUTHENTICATION_FAILED') + this.#publish(id, { type: 'login', operationId, status: controller.signal.aborted ? 'cancelled' : 'failed', errorCode: code }) if (generation === this.#manager.generation(id)) { - const code = controller.signal.aborted ? 'MCP_AUTHENTICATION_CANCELLED' : mcpErrorCode(error, 'MCP_AUTHENTICATION_FAILED') this.#manager.setState(id, code === 'MCP_CONNECTOR_CHANGED' ? 'error' : 'needs_auth', code) } } @@ -268,26 +301,34 @@ export class McpConnectorService { if (this.#logins.get(id)?.controller === controller) { this.#logins.delete(id) this.#manager.resume(id) - await this.#options.invalidateSessions?.() } } }) - this.#logins.set(id, { controller, operation }) + this.#logins.set(id, { controller, operation, operationId }) } cancelLogin(id: string): void { const login = this.#logins.get(id) - if (!login) + if (!login || login.controller.signal.aborted) return login.controller.abort() this.#manager.setState(id, 'needs_auth', 'MCP_AUTHENTICATION_CANCELLED') } + resourceRevisions(): readonly BuddyCapabilityResourceRevision[] { + return copyEventSnapshot(this.list().filter(record => this.#manager.available(record.id, this.#manager.generation(record.id))).map(record => ({ + source: 'connector' as const, + id: record.id, + revision: createHash('sha256').update(JSON.stringify([this.#manager.generation(record.id), this.#manager.catalogRevision(record.id), record.name])).digest('hex'), + }))) + } + getTools(_signal?: AbortSignal, writeResult?: McpResultWriter): BuddyMcpTools { const classifications = new Map() const diagnostics: BuddyMcpTools['diagnostics'] = [] const tools: ToolDefinition[] = [] const availability = new Map boolean>() + const resourceRevisions = this.resourceRevisions() for (const connector of this.list().filter(record => record.enabled)) { const generation = this.#manager.generation(connector.id) if (!this.#manager.available(connector.id, generation)) @@ -307,22 +348,38 @@ export class McpConnectorService { for (const [name, classification] of result.classifications) classifications.set(name, classification) } - return { classifications, diagnostics, tools, available: name => availability.get(name)?.() ?? false } + return { resourceRevisions: copyEventSnapshot(resourceRevisions), classifications, diagnostics, tools, available: name => availability.get(name)?.() ?? false } } - async close(): Promise { - this.#closed = true + async quiesce(): Promise { + this.#quiescing = true for (const login of this.#logins.values()) login.controller.abort() - await Promise.allSettled([...this.#logins.values()].map(login => login.operation)) - await Promise.allSettled([...this.#mutations.values(), ...this.#secretWrites.values()]) - await this.#manager.close() + const connections = this.#manager.quiesce() + do { + await Promise.allSettled([connections, ...[...this.#logins.values()].map(login => login.operation), ...this.#mutations.values(), ...this.#secretWrites.values(), ...this.#tests]) + } while (this.#logins.size || this.#mutations.size || this.#secretWrites.size || this.#tests.size) + } + + async close(): Promise { + await this.quiesce() + this.#closed = true + try { + await this.#manager.close() + } + finally { + this.#events.dispose() + } } - #mutate(id: string, action: () => Promise): Promise { + #mutate(id: string, action: () => Promise, shouldReset: () => boolean = () => true): Promise { + if (this.#quiescing) + return Promise.reject(new McpClientError('MCP_SERVER_UNAVAILABLE')) return enqueue(this.#mutations, id, async () => { if (this.#closed) throw new McpClientError('MCP_SERVER_UNAVAILABLE') + if (!shouldReset()) + return enqueue(this.#secretWrites, id, action) const login = this.#logins.get(id) this.cancelLogin(id) await login?.operation @@ -333,9 +390,7 @@ export class McpConnectorService { } finally { this.#manager.resume(id) - await this.#options.invalidateSessions?.() - this.#options.notify?.({ code: 'MCP_CONNECTOR_CHANGED', connectorId: id, type: 'connector.tools_changed' }) - if (!this.#closed) + if (!this.#closed && !this.#quiescing) this.#manager.warm(id) } }) @@ -354,14 +409,17 @@ export class McpConnectorService { const previous = await this.#secrets.read(ref) try { signal.throwIfAborted() - await this.#secrets.write(ref, credential) + await this.#writeCredential(ref, credential, id) signal.throwIfAborted() if (generation !== this.#manager.generation(id)) throw new McpClientError('MCP_CONNECTOR_CHANGED') - this.#connectors.upsert({ ...record, credentialRef: ref, updatedAt: new Date().toISOString() }) + if (record.credentialRef !== ref) { + const saved = this.#connectors.upsert({ ...record, credentialRef: ref, updatedAt: new Date().toISOString() }) + this.#configurationCommitted(record, saved) + } } catch (error) { - await this.#restoreSecret(ref, previous) + await this.#restoreSecret(ref, previous, id) throw error } }) @@ -376,14 +434,44 @@ export class McpConnectorService { if (parsed.data.transport === 'stdio' && parsed.data.enabled && !executionConfirmedAt) throw new McpConnectorError('MCP_EXECUTION_CONFIRMATION_REQUIRED') const now = new Date().toISOString() - return this.#connectors.upsert(toRecord(parsed.data, existing?.createdAt ?? now, now, executionConfirmedAt)) + const next = toRecord(parsed.data, existing?.createdAt ?? now, now, executionConfirmedAt) + if (existing && sameConfiguration(existing, parsed.data) && existing.credentialRef === next.credentialRef && existing.executionConfirmedAt === next.executionConfirmedAt) + return existing + const saved = this.#connectors.upsert(next) + this.#configurationCommitted(existing, saved) + return saved + } + + async #writeCredential(ref: string, credential: ConnectorCredential, connectorId: string): Promise { + await this.#secrets.write(ref, credential) + this.#publish(connectorId, { type: 'credential', status: 'written', presence: 'present' }) + } + + async #deleteCredential(ref: string, connectorId: string): Promise { + await this.#secrets.delete(ref) + this.#publish(connectorId, { type: 'credential', status: 'deleted', presence: 'absent' }) + } + + async #restoreSecret(ref: string, credential: ConnectorCredential | null, connectorId: string): Promise { + try { + if (credential) + await this.#secrets.write(ref, credential) + else + await this.#secrets.delete(ref) + this.#publish(connectorId, { type: 'credential', status: 'restored', presence: credential ? 'present' : 'absent' }) + } + catch (error) { + this.#publish(connectorId, { type: 'credential', status: 'restore-failed', presence: 'unknown' }) + throw error + } + } + + #configurationCommitted(previous: McpServerRecord | null | undefined, current: McpServerRecord): void { + this.#publish(current.id, { type: 'configuration', kind: previous ? 'updated' : 'created', enabled: current.enabled, executionChanged: !previous || !sameExecutionTarget(previous, toConfig(current)) || previous.enabled !== current.enabled || previous.executionConfirmedAt !== current.executionConfirmedAt || previous.credentialRef !== current.credentialRef }) } - async #restoreSecret(id: string, credential: ConnectorCredential | null): Promise { - if (credential) - await this.#secrets.write(id, credential) - else - await this.#secrets.delete(id) + #publish(connectorId: string, details: McpConnectorDetails): void { + this.#events.fire(copyEventSnapshot({ ...details, sourceId: this.sourceId, revision: ++this.#revision, connectorId, generation: this.#manager.generation(connectorId) })) } #requireConnector(id: string): McpServerRecord { @@ -519,3 +607,7 @@ function credentialMatchesTransport( ): boolean { return transport === 'stdio' ? credential.type === 'stdio' : credential.type === 'http' || credential.type === 'oauth' } + +function sameConfiguration(existing: McpServerRecord | null | undefined, config: McpServerConfig): boolean { + return !!existing && existing.name === config.name && existing.enabled === config.enabled && sameExecutionTarget(existing, config) +} diff --git a/apps/buddy/service/src/connectors/mcp/McpSessionResourceConsumer.ts b/apps/buddy/service/src/connectors/mcp/McpSessionResourceConsumer.ts new file mode 100644 index 00000000..e6f398e2 --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/McpSessionResourceConsumer.ts @@ -0,0 +1,69 @@ +import type { ApplicationDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' +import type { EventSubscription } from '../../../../shared/events/eventTypes' +import type { BuddyCapabilityResourceRevision } from '../../agent/extensions/BuddyCapability' +import type { SessionResourceReconciler } from '../../agent/resources/SessionResourceReconciler' +import type { BuddySessionRegistry, DisposableBuddySession } from '../../agent/sessions/BuddySessionRegistry' +import type { McpConnectorService } from './McpConnectorService' +import { safeDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' + +export class McpSessionResourceConsumer { + readonly #subscriptions: EventSubscription[] + readonly #service: McpConnectorService + readonly #sessions: BuddySessionRegistry + readonly #resources: SessionResourceReconciler + readonly #record: ApplicationDiagnosticReporter + #disposed = false + + constructor(options: { service: McpConnectorService, sessions: BuddySessionRegistry, resources: SessionResourceReconciler, report?: ApplicationDiagnosticReporter }) { + this.#service = options.service + this.#sessions = options.sessions + this.#resources = options.resources + this.#record = safeDiagnosticReporter(options.report) + this.#subscriptions = [ + options.service.onDidChange((event) => { + if (event.type === 'configuration') + void this.resync() + }), + options.service.onDidChangeConnection((event) => { + if (event.type === 'generation' || event.type === 'catalog' || event.type === 'availability') + void this.resync() + }), + options.sessions.onDidChange((event) => { + if (event.type === 'ready') + void this.resync() + }), + ] + void this.resync() + } + + async resync(): Promise { + if (this.#disposed) + return + const revision = resourceKey(this.#service.resourceRevisions()) + const sessionIds = this.#sessions.snapshot().filter(session => session.status === 'ready' && !session.invalidationPending && resourceKey(session.resourceRevisions.filter(resource => resource.source === 'connector')) !== revision).map(session => session.id) + if (!sessionIds.length) + return + try { + await this.#resources.reconcileInvalidation({ source: 'connector', scope: this.#service.sourceId, revision, sessionIds, matches: () => true, retry: true }) + } + catch { + this.#record({ event: 'connectors.sessions.reconciliation_failed', level: 'warn', errorCode: 'MCP_SESSION_RECONCILIATION_FAILED' }) + } + } + + whenIdle(): Promise { + return this.#resources.whenIdle() + } + + async dispose(): Promise { + await this.#service.quiesce() + await this.whenIdle() + this.#disposed = true + for (const subscription of this.#subscriptions) + subscription.dispose() + } +} + +function resourceKey(resources: readonly BuddyCapabilityResourceRevision[]): string { + return JSON.stringify([...resources].sort((left, right) => left.id.localeCompare(right.id))) +} diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectionEvents.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectionEvents.spec.ts new file mode 100644 index 00000000..5f598020 --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectionEvents.spec.ts @@ -0,0 +1,129 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { McpConnectionEvent } from '../mcpEvents' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createConnectorRepository } from '../../../storage/connectorRepository' +import { openBuddyDatabase } from '../../../storage/database' +import { createMcpTools } from '../createMcpTools' +import { McpClientSession } from '../McpClientSession' +import { McpConnectionManager } from '../McpConnectionManager' + +const databases: DatabaseSync[] = [] +const managers: McpConnectionManager[] = [] +const tools = [{ name: 'lookup', description: 'private description', inputSchema: { type: 'object' as const, properties: { query: { type: 'string' } } } }] + +afterEach(async () => { + await Promise.all(managers.splice(0).map(manager => manager.close())) + for (const database of databases.splice(0)) + database.close() + vi.restoreAllMocks() +}) + +describe('mcpConnectionEvents', () => { + it('publishes effective pause and resume availability after updating the manager', async () => { + const fixture = createFixture() + await fixture.manager.refresh('fixture') + const observed: boolean[] = [] + fixture.manager.onDidChange((event) => { + if (event.type === 'availability') + observed.push(fixture.manager.available(event.connectorId, event.generation)) + }) + fixture.manager.pause('fixture') + fixture.manager.pause('fixture') + await fixture.manager.reset('fixture') + fixture.manager.resume('fixture') + fixture.manager.resume('fixture') + expect(fixture.events.filter(event => event.type === 'availability')).toEqual([ + expect.objectContaining({ available: false, generation: 0 }), + expect.objectContaining({ available: true, generation: 1 }), + ]) + expect(observed).toEqual([false, true]) + }) + + it('accepts a durable catalog independently of observers and does not lend its schema to tool consumers', async () => { + const fixture = createFixture() + fixture.manager.onDidChange((event) => { + if (event.type === 'catalog') + throw new Error('observer failed after commit') + }) + await fixture.manager.refresh('fixture') + expect(fixture.manager.state('fixture')).toMatchObject({ status: 'ready', errorCode: null, toolCount: 1 }) + expect(JSON.parse(fixture.repository.readCatalog('fixture')!.toolsJson)[0].name).toBe('lookup') + const accepted = fixture.events.find(event => event.type === 'catalog')! + expect(accepted).toMatchObject({ generation: 0, type: 'catalog' }) + const catalog = fixture.manager.catalog('fixture') + expect(Reflect.set(catalog[0]!, 'description', 'changed')).toBe(false) + expect(Reflect.set(catalog[0]!.inputSchema.properties as object, 'query', {})).toBe(false) + const bound = createMcpTools({ tools: catalog, generation: 0, serverId: 'fixture', serverName: 'Fixture', callTool: async () => ({ content: [] }) }) + const parameters = bound.tools[0]!.parameters + Reflect.set(parameters, 'properties', { replacement: {} }) + expect(fixture.manager.catalog('fixture')[0]?.inputSchema.properties).toEqual({ query: { type: 'string' } }) + await fixture.manager.refresh('fixture') + expect(fixture.events.filter(event => event.type === 'catalog')).toHaveLength(1) + expect(fixture.observerFailures).toHaveLength(1) + }) + + it('does not accept a catalog produced by a reset connection generation', async () => { + const fixture = createFixture() + const entered = Promise.withResolvers() + const delayed = Promise.withResolvers() + fixture.listTools.mockImplementationOnce(() => { + entered.resolve() + return delayed.promise + }) + const refresh = fixture.manager.refresh('fixture') + await entered.promise + const reset = fixture.manager.reset('fixture') + delayed.resolve(tools) + await Promise.all([refresh, reset]) + expect(fixture.repository.readCatalog('fixture')).toBeNull() + expect(fixture.events.filter(event => event.type === 'catalog')).toEqual([]) + expect(fixture.manager.generation('fixture')).toBe(1) + await fixture.manager.refresh('fixture') + expect(fixture.events.filter(event => event.type === 'catalog')).toEqual([expect.objectContaining({ generation: 1 })]) + }) + + it('preserves the accepted catalog if persistence fails before a replacement commits', async () => { + const fixture = createFixture() + await fixture.manager.refresh('fixture') + fixture.events.length = 0 + fixture.listTools.mockResolvedValueOnce([{ name: 'replacement', inputSchema: { type: 'object' } }]) + vi.spyOn(fixture.repository, 'saveCatalog').mockImplementationOnce(() => { + throw new Error('storage unavailable') + }) + await expect(fixture.manager.refresh('fixture')).rejects.toThrow('storage unavailable') + expect(fixture.manager.catalog('fixture')[0]?.name).toBe('lookup') + expect(JSON.parse(fixture.repository.readCatalog('fixture')!.toolsJson)[0].name).toBe('lookup') + expect(fixture.events.some(event => event.type === 'catalog')).toBe(false) + expect(fixture.manager.state('fixture').status).toBe('error') + }) + + it('publishes one acceptance batch with captured generations during reentrant reset', async () => { + const fixture = createFixture() + let reset: Promise | undefined + fixture.manager.onDidChange((event) => { + if (event.type === 'state' && event.snapshot.status === 'ready' && !reset) + reset = fixture.manager.reset('fixture') + }) + await fixture.manager.refresh('fixture') + await reset + const changes = fixture.events.filter(event => event.type === 'catalog' || event.type === 'generation') + expect(changes.map(event => [event.type, event.generation])).toEqual([['catalog', 0], ['generation', 1]]) + expect(changes[0]!.revision).toBeLessThan(changes[1]!.revision) + expect(fixture.manager.generation('fixture')).toBe(1) + }) +}) + +function createFixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const repository = createConnectorRepository(database) + repository.upsert({ id: 'fixture', name: 'Fixture', transport: 'streamable-http', url: 'https://example.test/mcp?secret=private', args: null, command: null, cwd: null, enabled: true, credentialRef: null, executionConfirmedAt: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }) + const listTools = vi.spyOn(McpClientSession.prototype, 'listTools').mockResolvedValue(tools) + vi.spyOn(McpClientSession.prototype, 'close').mockResolvedValue() + const observerFailures: unknown[] = [] + const manager = new McpConnectionManager({ repository, readCredential: async () => null, authProvider: () => undefined, config: record => ({ id: record.id, name: record.name, transport: 'streamable-http', url: record.url!, enabled: record.enabled, credentialRef: record.credentialRef }), onListenerError: error => observerFailures.push(error) }) + managers.push(manager) + const events: McpConnectionEvent[] = [] + manager.onDidChange(event => events.push(event)) + return { manager, repository, events, observerFailures, listTools } +} diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts index c61e63bc..2deb036f 100644 --- a/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts @@ -1,7 +1,7 @@ import type { ToolDefinition } from '@earendil-works/pi-coding-agent' import type { DatabaseSync } from 'node:sqlite' import type { ConnectorCredential } from '../../../../../shared/connectors/connectorCredentials' -import type { BuddyConnectorEvent } from '../McpConnectorService' +import type { McpConnectionEvent, McpConnectorEvent } from '../mcpEvents' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -16,6 +16,7 @@ import { openBuddyDatabase } from '../../../storage/database' import { createMcpToolName } from '../createMcpTools' import { McpConnectorService } from '../McpConnectorService' import { classifyMcpTool } from '../mcpToolContract' +import { observeMcpDiagnostics } from '../observeMcpEvents' const services: McpConnectorService[] = [] const databases: DatabaseSync[] = [] @@ -30,6 +31,69 @@ afterEach(async () => { }) describe('mcpConnectorService', () => { + it('drains an accepted credential and configuration commit while retaining the source for observers', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const repository = createConnectorRepository(database) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const secrets = new Map() + const service = new McpConnectorService({ connectors: repository, secrets: { read: async id => secrets.get(id) ?? null, delete: async (id) => { + secrets.delete(id) + }, write: async (id, value) => { + entered.resolve() + + await release.promise + + secrets.set(id, value) + } } }) + services.push(service) + const events: McpConnectorEvent[] = [] + service.onDidChange(event => events.push(event)) + const accepted = service.save({ config: httpConfig('https://example.test/mcp'), credential: { mode: 'replace', value: { type: 'http', bearerToken: 'fixture-token' } } }) + await entered.promise + let stopped = false + const stopping = service.quiesce().then(() => { + stopped = true + }) + await Promise.resolve() + expect(stopped).toBe(false) + release.resolve() + const record = await accepted + await stopping + expect(repository.findById(record.id)?.credentialRef).toBe(record.id) + expect(secrets.has(record.id)).toBe(true) + expect(events.map(event => event.type)).toEqual(['credential', 'configuration']) + expect(JSON.stringify(events)).not.toContain('fixture-token') + await expect(service.setEnabled(record.id, true)).rejects.toMatchObject({ code: 'MCP_SERVER_UNAVAILABLE' }) + }) + + it('publishes no configuration or generation changes for unchanged disabled settings', async () => { + const fixture = await createFixture() + const configuration = httpConfig('https://example.test/mcp') + const first = await fixture.service.upsert(configuration) + const facts: McpConnectorEvent[] = [] + fixture.service.onDidChange(event => facts.push(event)) + fixture.events.length = 0 + expect(await fixture.service.upsert(configuration)).toEqual(first) + expect(await fixture.service.setEnabled('remote', false)).toEqual(first) + await fixture.service.clearCredential('remote') + expect(facts).toEqual([]) + expect(fixture.events).toEqual([]) + }) + + it('records credential compensation without publishing a failed SQL change or secret contents', async () => { + const fixture = createAtomicSaveFailureFixture() + const facts: McpConnectorEvent[] = [] + const diagnostics: unknown[] = [] + fixture.service.onDidChange(event => facts.push(event)) + observeMcpDiagnostics(fixture.service, event => diagnostics.push(event)) + await expect(fixture.service.save({ config: httpConfig('https://second.example.com/mcp'), credential: { mode: 'replace', value: { type: 'http', bearerToken: 'private-token' } } })).rejects.toThrow('database unavailable') + expect(facts.map(event => [event.type, event.type === 'credential' ? event.status : event.type])).toEqual([['credential', 'written'], ['credential', 'restored']]) + expect(JSON.stringify(diagnostics)).not.toContain('private-token') + expect(JSON.stringify(diagnostics)).not.toContain('example.com') + }) + it('lists and executes a real stdio server while classifying side effects for Buddy approval', async () => { const fixture = await createFixture() await fixture.service.upsert(stdioConfig(false)) @@ -107,7 +171,7 @@ describe('mcpConnectorService', () => { expect(JSON.stringify(fixture.service.list())).not.toContain('secret-value') await vi.waitUntil(() => fixture.events.some(event => ( - event.type === 'connector.unavailable' && event.code === 'MCP_SERVER_DISCONNECTED' + event.type === 'state' && event.snapshot.errorCode === 'MCP_SERVER_DISCONNECTED' ))) const result = await executeTool(tools.tools[0]!, { text: 'hello' }) expect(result).toMatchObject({ @@ -248,7 +312,9 @@ describe('mcpConnectorService', () => { bearerToken: 'old-secret', type: 'http', }) - fixture.invalidateSessions.mockRejectedValueOnce(new Error('session disposal failed')) + fixture.service.onDidChange(() => { + throw new Error('session disposal failed') + }) await expect(fixture.service.save({ config: httpConfig('https://second.example.com/mcp'), @@ -256,7 +322,7 @@ describe('mcpConnectorService', () => { mode: 'replace', value: { bearerToken: 'new-secret', type: 'http' }, }, - })).rejects.toThrow('session disposal failed') + })).resolves.toMatchObject({ url: 'https://second.example.com/mcp' }) expect(fixture.service.list()[0]?.url).toBe('https://second.example.com/mcp') expect(fixture.secrets.values.get('remote')).toEqual({ @@ -298,17 +364,16 @@ async function createFixture(maxReconnectAttempts?: number) { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) const values = new Map() - const events: BuddyConnectorEvent[] = [] - const invalidateSessions = vi.fn() + const events: McpConnectionEvent[] = [] + const observerFailures: unknown[] = [] const read = vi.fn(async (id: string) => { await new Promise(resolve => setImmediate(resolve)) return values.get(id) ?? null }) const service = new McpConnectorService({ connectors: createConnectorRepository(database), - invalidateSessions, + onListenerError: error => observerFailures.push(error), maxReconnectAttempts, - notify: event => events.push(event), secrets: { async delete(id) { values.delete(id) @@ -319,10 +384,11 @@ async function createFixture(maxReconnectAttempts?: number) { }, }, }) + service.onDidChangeConnection(event => events.push(event)) services.push(service) return { events, - invalidateSessions, + observerFailures, secrets: { read, values }, service, } diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpHttp.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpHttp.spec.ts index c6ff0288..f044643e 100644 --- a/apps/buddy/service/src/connectors/mcp/__tests__/McpHttp.spec.ts +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpHttp.spec.ts @@ -198,6 +198,7 @@ describe('mCP HTTP and authorization', () => { await service.upsert({ id: 'http', name: 'HTTP', url: `${fixture.base}/mcp`, transport: 'streamable-http', enabled: false, credentialRef: null }) await service.saveCredential('http', secret as never) expect(await service.test('http')).toMatchObject({ authorization: 'oauth', status: 'needs_auth' }) + expect(service.state('http')).toMatchObject({ authorization: 'oauth', status: 'disabled', errorCode: 'MCP_AUTHENTICATION_REQUIRED' }) service.login('http') await vi.waitUntil(() => service.state('http').errorCode === 'MCP_AUTHENTICATION_FAILED') expect(secret).toEqual({ type: 'http', bearerToken: 'previous-fixture-token' }) diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpLifecycle.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpLifecycle.spec.ts index 83d4e298..9f8f8937 100644 --- a/apps/buddy/service/src/connectors/mcp/__tests__/McpLifecycle.spec.ts +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpLifecycle.spec.ts @@ -65,7 +65,17 @@ describe('mCP global lifecycle', () => { const { service } = fixture() await service.upsert(config) await service.confirmExecution(config.id) - expect(await service.test(config.id)).toMatchObject({ status: 'ready', toolCount: 4 }) + const states: string[] = [] + service.onDidChangeConnection((event) => { + if (event.type === 'state') + states.push(event.snapshot.status) + }) + const result = await service.test(config.id) + expect(result).toMatchObject({ status: 'ready', toolCount: 4 }) + expect(Reflect.set(result, 'status', 'error')).toBe(false) + expect(service.state(config.id)).toMatchObject({ status: 'disabled', errorCode: null, toolCount: 4 }) + expect(states.at(-1)).toBe('disabled') + expect(states).not.toContain('ready') expect(service.list()[0]?.enabled).toBe(false) expect(service.getTools().tools).toHaveLength(0) }) diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpSessionResources.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpSessionResources.spec.ts new file mode 100644 index 00000000..46051fba --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpSessionResources.spec.ts @@ -0,0 +1,174 @@ +import type { SessionResourceChange } from '../../../agent/resources/SessionResourceReconciler' +import type { SkillEvent } from '../../../skills/skillEvents' +import type { McpConnectionEvent } from '../mcpEvents' +import { describe, expect, it, vi } from 'vitest' +import { Emitter, filterEvent } from '../../../../../shared/events/Emitter' +import { SessionResourceReconciler } from '../../../agent/resources/SessionResourceReconciler' +import { BuddySessionRegistry } from '../../../agent/sessions/BuddySessionRegistry' +import { createConnectorRepository } from '../../../storage/connectorRepository' +import { openBuddyDatabase } from '../../../storage/database' +import { McpClientSession } from '../McpClientSession' +import { McpConnectorService } from '../McpConnectorService' +import { McpSessionResourceConsumer } from '../McpSessionResourceConsumer' + +const identity = { approvalPolicy: 'policy' as const, branchId: 'branch', canonicalRoot: '/workspace', conversationId: 'conversation', executionProfile: 'workspace_write' as const, grantRevision: 'grant', resourceRevision: 'resources', skillRevision: 'skills', scratchRoot: '/scratch', sessionMode: 'interactive' as const, spaceId: null } +const config = { id: 'fixture', name: 'Fixture', transport: 'streamable-http' as const, url: 'https://example.test/mcp', enabled: true, credentialRef: null } + +interface InstalledSession { + readonly toolNames: readonly string[] + closed: boolean + shutdown: () => Promise +} + +describe('mcpSessionResources', () => { + it('defers an effective connector update until run release and reports the versions actually installed by the next session', async () => { + const fixture = createFixture() + const { service, sessions, consumer, applied } = fixture + const gate = Promise.withResolvers() + let run: Promise | undefined + try { + await service.upsert({ ...config, enabled: false }) + const first = await sessions.getOrCreate(identity, null, fixture.createSession) + await consumer.whenIdle() + run = sessions.withConversationRun(identity, 'run', undefined, () => gate.promise) + await vi.waitFor(() => expect(sessions.getActiveRun(identity)?.runId).toBe('run')) + await service.setEnabled('fixture', true) + await service.prepareForRun(new AbortController().signal) + await consumer.whenIdle() + expect(sessions.snapshot()[0]?.invalidationPending).toBe(true) + expect(first.session.closed).toBe(false) + expect(applied).toEqual([expect.objectContaining({ capabilityRevisions: [] })]) + gate.resolve() + await run + expect(first.session.closed).toBe(true) + const installed = service.getTools().resourceRevisions! + await sessions.getOrCreate(identity, null, fixture.createSession) + await consumer.whenIdle() + expect(sessions.snapshot()[0]?.invalidationPending).toBe(false) + expect(applied.at(-1)).toMatchObject({ type: 'applied', capabilityRevisions: installed }) + expect(installed).toEqual([expect.objectContaining({ source: 'connector', id: 'fixture' })]) + } + finally { + gate.resolve() + await run + await fixture.dispose() + } + }) + + it.each(['rename', 'failed reset', 'failed configuration write'] as const)('rebuilds a session prepared with a paused catalog after %s once its run is released', async (operation) => { + const fixture = createFixture() + const { service, sessions, consumer } = fixture + const closeEntered = Promise.withResolvers() + const closeReleased = Promise.withResolvers() + const runEntered = Promise.withResolvers() + const runReleased = Promise.withResolvers() + const warmed = Promise.withResolvers() + let mutation: Promise | undefined + let run: Promise | undefined + try { + await service.upsert(config) + await service.prepareForRun(new AbortController().signal) + expect(service.getTools().tools).toHaveLength(1) + const changes: McpConnectionEvent[] = [] + service.onDidChangeConnection((event) => { + changes.push(event) + if (event.type === 'state' && event.snapshot.status === 'ready') + warmed.resolve() + }) + fixture.close.mockImplementationOnce(async () => { + closeEntered.resolve() + await closeReleased.promise + if (operation === 'failed reset') + throw new Error('connection cleanup failed') + }) + if (operation === 'failed configuration write') { + vi.spyOn(fixture.repository, 'upsert').mockImplementationOnce(() => { + throw new Error('configuration write failed') + }) + } + const result = service.upsert({ ...config, name: 'Renamed' }).then( + () => ({ status: 'fulfilled' as const }), + error => ({ status: 'rejected' as const, error }), + ) + mutation = result + await closeEntered.promise + await service.prepareForRun(new AbortController().signal) + const first = await sessions.getOrCreate(identity, null, fixture.createSession) + await consumer.whenIdle() + expect(first.session.toolNames).toEqual([]) + expect(sessions.snapshot()[0]).toMatchObject({ resourceRevisions: [], invalidationPending: false }) + run = sessions.withConversationRun(identity, 'run', undefined, () => { + runEntered.resolve() + return runReleased.promise + }) + await runEntered.promise + closeReleased.resolve() + expect(await result).toMatchObject({ status: operation === 'rename' ? 'fulfilled' : 'rejected' }) + await warmed.promise + await consumer.whenIdle() + expect(fixture.repository.findById('fixture')?.name).toBe(operation === 'rename' ? 'Renamed' : 'Fixture') + expect(changes.filter(event => event.type === 'catalog')).toEqual([]) + expect(service.getTools().tools).toHaveLength(1) + expect(sessions.snapshot()[0]?.invalidationPending).toBe(true) + expect(sessions.getActiveRun(identity)?.runId).toBe('run') + expect(first.session.closed).toBe(false) + runReleased.resolve() + await run + expect(first.session.closed).toBe(true) + const next = await sessions.getOrCreate(identity, '/session', fixture.createSession) + await consumer.whenIdle() + expect(next).not.toBe(first) + expect(next.session.toolNames).toEqual(service.getTools().tools.map(tool => tool.name)) + expect(sessions.snapshot()[0]).toMatchObject({ resourceRevisions: service.resourceRevisions(), invalidationPending: false }) + } + finally { + closeReleased.resolve() + runReleased.resolve() + await mutation + await run + await fixture.dispose() + } + }) +}) + +function createFixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const repository = createConnectorRepository(database) + const service = new McpConnectorService({ connectors: repository, secrets: { read: async () => null, write: async () => {}, delete: async () => {} } }) + vi.spyOn(McpClientSession.prototype, 'listTools').mockResolvedValue([{ name: 'lookup', inputSchema: { type: 'object' } }]) + const close = vi.spyOn(McpClientSession.prototype, 'close').mockResolvedValue() + const source = new Emitter(() => {}) + const resolution = { revision: 'skills', skills: [], references: [], paths: [], readRoots: [], diagnostics: [] } + const sessions = new BuddySessionRegistry() + const resources = new SessionResourceReconciler({ sessions, skills: { quiesce: async () => {}, onDidCommitInstallation: filterEvent(source.event, (event): event is Extract => event.type === 'installation'), onDidChangeResources: filterEvent(source.event, (event): event is Extract => event.type === 'resources'), loadForSpace: async () => resolution, resourceSnapshots: () => [{ spaceId: null, resolution }] } }) + const consumer = new McpSessionResourceConsumer({ service, sessions, resources }) + const applied: SessionResourceChange[] = [] + resources.onDidChange((event) => { + if (event.type === 'applied') + applied.push(event) + }) + return { + repository, + service, + sessions, + consumer, + applied, + close, + createSession: async () => { + const tools = service.getTools() + const session: InstalledSession = { toolNames: tools.tools.map(tool => tool.name), closed: false, shutdown: async () => { + session.closed = true + } } + return { piSessionFile: '/session', resourceRevisions: tools.resourceRevisions, session } + }, + dispose: async () => { + await consumer.dispose() + await resources.dispose() + await sessions.dispose() + await service.close() + source.dispose() + database.close() + vi.restoreAllMocks() + }, + } +} diff --git a/apps/buddy/service/src/connectors/mcp/createMcpTools.ts b/apps/buddy/service/src/connectors/mcp/createMcpTools.ts index e468bb6e..acfdfe50 100644 --- a/apps/buddy/service/src/connectors/mcp/createMcpTools.ts +++ b/apps/buddy/service/src/connectors/mcp/createMcpTools.ts @@ -2,10 +2,11 @@ import type { ToolDefinition } from '@earendil-works/pi-coding-agent' import type { CallToolResult, Progress } from '@modelcontextprotocol/client' import type { TSchema } from 'typebox' import type { BuddyToolClassification } from '../../approvals/toolClassification' -import type { McpRemoteTool } from './McpClientSession' +import type { McpCatalogTool } from './mcpEvents' import type { McpResultWriter } from './mcpToolResults' import { createHash } from 'node:crypto' import { defineTool } from '@earendil-works/pi-coding-agent' +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { mcpErrorCode } from './mcpErrors' import { normalizeMcpResult } from './mcpToolResults' @@ -13,8 +14,8 @@ export interface CreateMcpToolsOptions { serverId: string serverName: string generation: number - callTool: (tool: McpRemoteTool, arguments_: unknown, signal?: AbortSignal, onProgress?: (progress: Progress) => void) => Promise - tools: readonly McpRemoteTool[] + callTool: (tool: McpCatalogTool, arguments_: unknown, signal?: AbortSignal, onProgress?: (progress: Progress) => void) => Promise + tools: readonly McpCatalogTool[] writeResult?: McpResultWriter } @@ -39,7 +40,8 @@ export function createMcpTools(options: CreateMcpToolsOptions): McpToolsResult { const diagnostics: McpToolsResult['diagnostics'] = [] const tools: ToolDefinition[] = [] const names = new Set() - for (const remoteTool of options.tools) { + for (const sourceTool of options.tools) { + const remoteTool = copyEventSnapshot(sourceTool) const name = createMcpToolName(options.serverId, remoteTool.name) if (names.has(name)) { diagnostics.push({ code: 'MCP_TOOL_INVALID', message: 'MCP tool names conflict' }) @@ -50,7 +52,7 @@ export function createMcpTools(options: CreateMcpToolsOptions): McpToolsResult { name, label: `${options.serverName} · ${remoteTool.title ?? remoteTool.name}`, description: `${options.serverName}: ${remoteTool.description ?? remoteTool.name}`.slice(0, 2048), - parameters: remoteTool.inputSchema as TSchema, + parameters: structuredClone(remoteTool.inputSchema) as TSchema, execute: async (_toolCallId, parameters, signal, onUpdate, context) => { const details: McpToolDetails = { connector: options.serverName, connectorTool: remoteTool.name, artifactIds: [] } let lastProgress = 0 @@ -85,7 +87,7 @@ export function createMcpToolName(serverId: string, toolName: string): string { return `mcp__${hash(serverId)}__${readable}_${hash(toolName)}` } -function classifyTool(options: Pick, tool: McpRemoteTool): BuddyToolClassification { +function classifyTool(options: Pick, tool: McpCatalogTool): BuddyToolClassification { return { access: 'network', approval: { diff --git a/apps/buddy/service/src/connectors/mcp/mcpCatalog.ts b/apps/buddy/service/src/connectors/mcp/mcpCatalog.ts index ada72802..a20f4620 100644 --- a/apps/buddy/service/src/connectors/mcp/mcpCatalog.ts +++ b/apps/buddy/service/src/connectors/mcp/mcpCatalog.ts @@ -1,4 +1,4 @@ -import type { McpRemoteTool } from './McpClientSession' +import type { McpCatalogTool } from './mcpEvents' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { z } from 'zod' @@ -21,7 +21,7 @@ const toolSchema = z.object({ const catalogSchema = z.array(toolSchema).max(1_024) const MAX_CATALOG_BYTES = 4 * 1024 * 1024 -export function parseMcpCatalog(value: unknown): McpRemoteTool[] { +export function parseMcpCatalog(value: unknown): McpCatalogTool[] { const parsed = catalogSchema.safeParse(value) if (!parsed.success) throw new McpClientError('MCP_TOOL_INVALID') @@ -37,12 +37,12 @@ export function parseMcpCatalog(value: unknown): McpRemoteTool[] { return tools } -export function readMcpCatalog(value: string): McpRemoteTool[] { +export function readMcpCatalog(value: string): McpCatalogTool[] { if (Buffer.byteLength(value) > MAX_CATALOG_BYTES) throw new McpClientError('MCP_TOOL_INVALID') return parseMcpCatalog(JSON.parse(value)) } -export function mcpToolFingerprint(tool: McpRemoteTool): string { +export function mcpToolFingerprint(tool: McpCatalogTool): string { return createHash('sha256').update(JSON.stringify({ name: tool.name, input: tool.inputSchema, output: tool.outputSchema, annotations: tool.annotations })).digest('hex') } diff --git a/apps/buddy/service/src/connectors/mcp/mcpEvents.ts b/apps/buddy/service/src/connectors/mcp/mcpEvents.ts new file mode 100644 index 00000000..a280d972 --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/mcpEvents.ts @@ -0,0 +1,40 @@ +import type { ConnectorErrorCode, ConnectorRuntimeState } from '../../../../shared/connectors/connectorState' + +export interface McpCatalogTool { + readonly name: string + readonly title?: string + readonly description?: string + readonly inputSchema: Readonly> & { readonly type: 'object' } + readonly outputSchema?: Readonly> + readonly annotations?: { + readonly title?: string + readonly readOnlyHint?: boolean + readonly destructiveHint?: boolean + readonly idempotentHint?: boolean + readonly openWorldHint?: boolean + } +} + +export interface McpEventIdentity { + readonly sourceId: string + readonly revision: number + readonly connectorId: string + readonly generation: number +} + +export type McpConnectionDetails = ( + | { readonly type: 'generation', readonly previousGeneration: number } + | { readonly type: 'availability', readonly available: boolean } + | { readonly type: 'state', readonly snapshot: Readonly } + | { readonly type: 'catalog', readonly catalogRevision: string, readonly previousRevision: string | null, readonly tools: readonly McpCatalogTool[] } + | { readonly type: 'cleanup', readonly status: 'completed' | 'degraded' } +) + +export type McpConnectorDetails = ( + | { readonly type: 'configuration', readonly kind: 'created' | 'updated' | 'removed', readonly enabled: boolean, readonly executionChanged: boolean } + | { readonly type: 'credential', readonly status: 'written' | 'deleted' | 'restored' | 'restore-failed', readonly presence: 'present' | 'absent' | 'unknown' } + | { readonly type: 'login', readonly operationId: string, readonly status: 'started' | 'succeeded' | 'failed' | 'cancelled', readonly errorCode?: ConnectorErrorCode } +) + +export type McpConnectionEvent = McpEventIdentity & McpConnectionDetails +export type McpConnectorEvent = McpEventIdentity & McpConnectorDetails diff --git a/apps/buddy/service/src/connectors/mcp/mcpExtension.ts b/apps/buddy/service/src/connectors/mcp/mcpExtension.ts index 13d14e0d..53aa05b1 100644 --- a/apps/buddy/service/src/connectors/mcp/mcpExtension.ts +++ b/apps/buddy/service/src/connectors/mcp/mcpExtension.ts @@ -8,6 +8,7 @@ import { classifyMcpTool } from './mcpToolContract' export function createMcpCapability(mcp: BuddyMcpTools): BuddyCapability { return { + resourceRevisions: mcp.resourceRevisions, extension: createMcpExtension({ tools: mcp.tools }), classify: (event) => { const classification = classifyMcpTool(mcp.classifications, event) diff --git a/apps/buddy/service/src/connectors/mcp/observeMcpEvents.ts b/apps/buddy/service/src/connectors/mcp/observeMcpEvents.ts new file mode 100644 index 00000000..c5f2f69a --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/observeMcpEvents.ts @@ -0,0 +1,45 @@ +import type { ConnectorChangeNotice } from '../../../../shared/connectors/connectorApi' +import type { ApplicationDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' +import type { McpConnectorService } from './McpConnectorService' +import { safeDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' + +export function observeMcpDiagnostics(service: McpConnectorService, report: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report) + const subscriptions = [ + service.onDidChange((event) => { + const common = { component: 'runtime.connectors', connectorId: event.connectorId, producerInstanceId: event.sourceId, sourceSequence: event.revision, generation: String(event.generation) } + if (event.type === 'configuration') + record({ ...common, event: `connectors.configuration.${event.kind}`, level: 'info' }) + else if (event.type === 'credential') + record({ ...common, event: `connectors.credential.${event.status.replaceAll('-', '_')}`, level: event.status === 'restore-failed' ? 'warn' : 'debug' }) + else + record({ ...common, event: `connectors.login.${event.status}`, level: event.status === 'failed' ? 'warn' : 'info', operationId: event.operationId, ...(event.errorCode ? { errorCode: event.errorCode } : {}) }) + }), + service.onDidChangeConnection((event) => { + const common = { component: 'runtime.connectors', connectorId: event.connectorId, producerInstanceId: event.sourceId, sourceSequence: event.revision, generation: String(event.generation) } + if (event.type === 'state') + record({ ...common, event: `connectors.connection.${event.snapshot.status}`, level: event.snapshot.errorCode ? 'warn' : 'debug', ...(event.snapshot.errorCode ? { errorCode: event.snapshot.errorCode } : {}) }) + else if (event.type === 'catalog') + record({ ...common, event: 'connectors.catalog.accepted', level: 'debug', count: event.tools.length }) + else if (event.type === 'cleanup') + record({ ...common, event: `connectors.cleanup.${event.status}`, level: event.status === 'degraded' ? 'warn' : 'debug' }) + else if (event.type === 'availability') + record({ ...common, event: `connectors.availability.${event.available ? 'available' : 'unavailable'}`, level: 'debug' }) + else + record({ ...common, event: 'connectors.generation.changed', level: 'debug' }) + }), + ] + return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) } +} + +export function observeMcpNotifications(service: McpConnectorService, notify: (event: ConnectorChangeNotice) => void) { + const subscriptions = [ + service.onDidChange(event => notify(Object.freeze({ sourceId: event.sourceId, revision: event.revision, generation: event.generation, connectorId: event.connectorId, type: event.type }))), + service.onDidChangeConnection((event) => { + if (event.type === 'cleanup') + return + notify(Object.freeze({ sourceId: event.sourceId, revision: event.revision, generation: event.generation, connectorId: event.connectorId, type: event.type === 'catalog' ? 'catalog' : 'runtime' })) + }), + ] + return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) } +} diff --git a/apps/buddy/service/src/conversations/ConversationLifecycleService.ts b/apps/buddy/service/src/conversations/ConversationLifecycleService.ts index fbd4cacb..121094b2 100644 --- a/apps/buddy/service/src/conversations/ConversationLifecycleService.ts +++ b/apps/buddy/service/src/conversations/ConversationLifecycleService.ts @@ -1,64 +1,110 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { ConversationDeletionCommit, ConversationDeletionRepository } from '../storage/conversationDeletionRepository' import type { ConversationRepository } from '../storage/conversationRepository' +import { randomUUID } from 'node:crypto' +import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' const conversationIdentityPattern = /^[A-Z0-9][\w-]{0,127}$/i export interface ConversationLifecycleServiceOptions { - conversations: Pick - directoryGrants: { - revokeAll: (conversationId: string, revokedAt: string) => number - } - runner: { - cancelAndWaitForConversation: (conversationId: string) => Promise - } - sessions: { - invalidateConversation: (conversationId: string) => Promise - } + conversations: Pick + deletion: ConversationDeletionRepository + runner: { cancelAndWaitForConversation: (conversationId: string) => Promise } + cancelQueuedRuns: (conversationId: string) => Promise + sessions: { invalidateConversation: (conversationId: string) => Promise<{ pending: number, degraded: number }> } + onObserverError?: (error: unknown) => void } +export type ConversationDeletionFact = EventSnapshot +export type ConversationCleanupFact = Readonly<{ + conversationId: string + operationId: string + reason: 'delete' | 'recovery' + status: 'started' | 'completed' | 'failed' + errorCode?: string +}> + export class ConversationLifecycleService { - readonly #conversations: ConversationLifecycleServiceOptions['conversations'] - readonly #deleting = new Set() - readonly #directoryGrants: ConversationLifecycleServiceOptions['directoryGrants'] - readonly #runner: ConversationLifecycleServiceOptions['runner'] - readonly #sessions: ConversationLifecycleServiceOptions['sessions'] + readonly #options: ConversationLifecycleServiceOptions + readonly #deleting = new Map>() + readonly #completed = new Set() + readonly #committed: Emitter + readonly #cleanup: Emitter + readonly onDidCommit: Emitter['event'] + readonly onDidCleanup: Emitter['event'] + #stopping = false constructor(options: ConversationLifecycleServiceOptions) { - this.#conversations = options.conversations - this.#directoryGrants = options.directoryGrants - this.#runner = options.runner - this.#sessions = options.sessions + this.#options = options + this.#committed = new Emitter(options.onObserverError ?? (() => {})) + this.#cleanup = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCommit = this.#committed.event + this.onDidCleanup = this.#cleanup.event } - async delete(conversationId: string): Promise { - if (!conversationIdentityPattern.test(conversationId)) - throw new ConversationLifecycleError() - const conversation = this.#conversations.findById(conversationId) - if (!conversation || conversation.deletedAt !== null) - return false - if (this.#deleting.has(conversationId)) - throw new ConversationLifecycleError() - - this.#deleting.add(conversationId) - try { - const deletedAt = new Date().toISOString() - if (!this.#conversations.markDeleted(conversationId, deletedAt)) - return false - this.#directoryGrants.revokeAll(conversationId, deletedAt) - await this.#runner.cancelAndWaitForConversation(conversationId) - await this.#sessions.invalidateConversation(conversationId) - return true - } - finally { + delete(conversationId: string, reason: 'delete' | 'recovery' = 'delete'): Promise { + if (this.#stopping || !conversationIdentityPattern.test(conversationId)) + return Promise.reject(new ConversationLifecycleError()) + const current = this.#deleting.get(conversationId) + if (current) + return current + if (this.#completed.has(conversationId)) + return Promise.resolve(false) + const result = Promise.withResolvers() + this.#deleting.set(conversationId, result.promise) + void this.#delete(conversationId, reason).then((deleted) => { this.#deleting.delete(conversationId) - } + result.resolve(deleted) + }, (error) => { + this.#deleting.delete(conversationId) + result.reject(error) + }) + return result.promise } isDeleting(conversationId: string): boolean { - return this.#deleting.has(conversationId) || this.#conversations.isDeleted(conversationId) + return this.#deleting.has(conversationId) || this.#options.conversations.isDeleted(conversationId) + } + + async recoverPendingDeletions(): Promise { + const pending = this.#options.deletion.pending() + for (const conversationId of pending) + await this.delete(conversationId, 'recovery') + return pending.length } - recoverPendingDeletions(): Promise { - return Promise.resolve(0) + async dispose(): Promise { + this.#stopping = true + await Promise.allSettled(this.#deleting.values()) + this.#committed.dispose() + this.#cleanup.dispose() + this.#completed.clear() + } + + async #delete(conversationId: string, reason: ConversationCleanupFact['reason']): Promise { + const committed = this.#options.deletion.commit(conversationId, new Date().toISOString()) + if (!committed) + return false + if (committed.tombstoned || committed.revokedGrantIds.length) + this.#committed.fire(copyEventSnapshot({ ...committed, commitId: randomUUID() })) + const operationId = randomUUID() + this.#cleanup.fire(copyEventSnapshot({ conversationId, operationId, reason, status: 'started' })) + try { + await this.#options.runner.cancelAndWaitForConversation(conversationId) + await this.#options.cancelQueuedRuns(conversationId) + const cleanup = await this.#options.sessions.invalidateConversation(conversationId) + if (cleanup.pending || cleanup.degraded) + throw new ConversationCleanupError(cleanup.degraded ? 'CONVERSATION_CLEANUP_FAILED' : 'CONVERSATION_CLEANUP_PENDING') + this.#completed.add(conversationId) + this.#cleanup.fire(copyEventSnapshot({ conversationId, operationId, reason, status: 'completed' })) + return committed.tombstoned + } + catch (error) { + this.#cleanup.fire(copyEventSnapshot({ conversationId, operationId, reason, status: 'failed', errorCode: readDiagnosticErrorCode(error) })) + throw error + } } } @@ -70,3 +116,13 @@ export class ConversationLifecycleError extends Error { this.name = 'ConversationLifecycleError' } } + +class ConversationCleanupError extends Error { + readonly code: 'CONVERSATION_CLEANUP_FAILED' | 'CONVERSATION_CLEANUP_PENDING' + + constructor(code: 'CONVERSATION_CLEANUP_FAILED' | 'CONVERSATION_CLEANUP_PENDING') { + super('Lexora Buddy conversation cleanup is incomplete') + this.name = 'ConversationCleanupError' + this.code = code + } +} diff --git a/apps/buddy/service/src/conversations/ConversationMetadataService.ts b/apps/buddy/service/src/conversations/ConversationMetadataService.ts new file mode 100644 index 00000000..5b416de9 --- /dev/null +++ b/apps/buddy/service/src/conversations/ConversationMetadataService.ts @@ -0,0 +1,174 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { ConversationModelSelection, ConversationRecord } from '../storage/conversationRecord' +import type { ConversationRepository, RenameConversationInput, SetConversationPermissionSettingsInput } from '../storage/conversationRepository' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { BuddyServiceError } from '../rpc/runtimeRequest' + +export type ConversationMetadataCommit = EventSnapshot<{ + commitId: string + kind: 'title' | 'permissions' | 'model' | 'branch' + conversation: ConversationRecord + titleRevision?: number + titleSource?: 'manual' | 'fallback' | 'generated' +}> + +export interface ConversationMetadataServiceOptions { + repository: Pick + resolveModelSelection: (selection: ConversationModelSelection) => Promise + sessions: { invalidateConversation: (conversationId: string) => Promise<{ pending: number, degraded: number }> } + onObserverError?: (error: unknown) => void +} + +export class ConversationMetadataService { + readonly #options: ConversationMetadataServiceOptions + readonly #committed: Emitter + readonly #pendingPermissions = new Map() + readonly #pending = new Set>() + readonly onDidCommit: Emitter['event'] + #disposed = false + + constructor(options: ConversationMetadataServiceOptions) { + this.#options = options + this.#committed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCommit = this.#committed.event + } + + getTitleState(conversationId: string) { + return this.#options.repository.getTitleState(conversationId) + } + + rename(input: RenameConversationInput): ConversationRecord { + this.#requireActive(input.id) + const conversation = this.#options.repository.rename(input) + this.#publish('title', conversation) + return conversation + } + + renameGenerated(input: RenameConversationInput & { expectedRevision: number }): ConversationRecord | null { + if (this.#disposed) + throw new BuddyServiceError('VALIDATION_FAILED') + const conversation = this.#options.repository.renameGenerated(input) + if (conversation) + this.#publish('title', conversation) + return conversation + } + + setPermissionSettings(input: Omit): Promise { + return this.#run(() => this.#setPermissionSettings({ ...input })) + } + + async #setPermissionSettings(input: Omit): Promise { + const { id, approvalPolicy, executionProfile } = input + const current = this.#requireActive(id) + if (current.approvalPolicy === approvalPolicy && current.executionProfile === executionProfile) { + await this.#applyPermissions(id) + return current + } + const conversation = this.#options.repository.setPermissionSettings({ id, approvalPolicy, executionProfile, updatedAt: new Date().toISOString() }) + if (!conversation) + throw new BuddyServiceError('VALIDATION_FAILED') + this.#pendingPermissions.set(id, Symbol('permission-invalidation')) + this.#publish('permissions', conversation) + await this.#applyPermissions(id) + return conversation + } + + setModelSelection(input: { id: string, modelSelection: ConversationModelSelection }): Promise { + return this.#run(() => this.#setModelSelection({ id: input.id, modelSelection: { ...input.modelSelection } })) + } + + async #setModelSelection(input: { id: string, modelSelection: ConversationModelSelection }): Promise { + const id = input.id + this.#requireActive(id) + const resolved = await this.#options.resolveModelSelection({ ...input.modelSelection }) + const current = this.#requireActive(id) + const selection: ConversationModelSelection = { + providerId: resolved.providerId, + modelId: resolved.modelId, + reasoning: resolved.reasoning, + serviceTier: resolved.serviceTier, + } + if (sameSelection(current.modelSelection, selection)) + return current + const conversation = this.#options.repository.setModelSelection({ id, modelSelection: selection, updatedAt: new Date().toISOString() }) + if (!conversation) + throw new BuddyServiceError('VALIDATION_FAILED') + this.#publish('model', conversation) + return conversation + } + + activateBranch(input: { conversationId: string, branchId: string }): ConversationRecord { + const current = this.#requireActive(input.conversationId) + if (current.activeBranchId === input.branchId) + return current + const conversation = this.#options.repository.activateBranch({ ...input, updatedAt: new Date().toISOString() }) + this.#publish('branch', conversation) + return conversation + } + + async dispose(): Promise { + this.#disposed = true + await Promise.allSettled(this.#pending) + this.#committed.dispose() + } + + #run(operation: () => Promise): Promise { + if (this.#disposed) + return Promise.reject(new BuddyServiceError('VALIDATION_FAILED')) + const pending = Promise.withResolvers() + this.#pending.add(pending.promise) + void operation().then((value) => { + this.#pending.delete(pending.promise) + pending.resolve(value) + }, (error) => { + this.#pending.delete(pending.promise) + pending.reject(error) + }) + return pending.promise + } + + #requireActive(id: string): ConversationRecord { + const conversation = this.#options.repository.findById(id) + if (this.#disposed || !conversation || conversation.deletedAt !== null) + throw new BuddyServiceError('VALIDATION_FAILED') + return conversation + } + + async #applyPermissions(id: string): Promise { + const pending = this.#pendingPermissions.get(id) + if (!pending) + return + const result = await this.#options.sessions.invalidateConversation(id) + if (result.pending || result.degraded) + throw new ConversationSettingsApplicationError(result.degraded ? 'CONVERSATION_SETTINGS_FAILED' : 'CONVERSATION_SETTINGS_PENDING') + if (this.#pendingPermissions.get(id) === pending) + this.#pendingPermissions.delete(id) + } + + #publish(kind: ConversationMetadataCommit['kind'], conversation: ConversationRecord): void { + const title = kind === 'title' ? this.#options.repository.getTitleState(conversation.id) : null + this.#committed.fire(copyEventSnapshot({ + commitId: randomUUID(), + kind, + conversation, + ...(title ? { titleRevision: title.revision, titleSource: title.source } : {}), + })) + } +} + +class ConversationSettingsApplicationError extends Error { + readonly code: 'CONVERSATION_SETTINGS_FAILED' | 'CONVERSATION_SETTINGS_PENDING' + + constructor(code: ConversationSettingsApplicationError['code']) { + super('Lexora Buddy conversation settings are committed but session invalidation is incomplete') + this.name = 'ConversationSettingsApplicationError' + this.code = code + } +} + +function sameSelection(left: ConversationModelSelection | null, right: ConversationModelSelection): boolean { + return left?.modelId === right.modelId && left.providerId === right.providerId + && left.reasoning === right.reasoning && left.serviceTier === right.serviceTier +} diff --git a/apps/buddy/service/src/conversations/TaskAttentionProjection.ts b/apps/buddy/service/src/conversations/TaskAttentionProjection.ts new file mode 100644 index 00000000..457af6b8 --- /dev/null +++ b/apps/buddy/service/src/conversations/TaskAttentionProjection.ts @@ -0,0 +1,147 @@ +import type { LocalTaskMark, LocalTaskMarkState } from '../../../shared/conversation/taskMarkApi' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { RunEventObservation } from '../events/RunEventPorts' +import type { RunLifecycleService } from '../runs/RunLifecycleService' +import type { RunRepository } from '../storage/runRepository' +import type { TaskMarkRepository } from '../storage/taskMarkRepository' +import type { ConversationLifecycleService } from './ConversationLifecycleService' +import type { TaskMarkService } from './TaskMarkService' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' + +export type TaskAttentionChange = EventSnapshot<{ + revision: number + conversationIds: string[] + marksChanged: boolean +}> + +export interface TaskAttentionProjectionOptions { + marks: Pick + eventLog: Pick + repository: Pick + runs: Pick + onError: (error: unknown) => void +} + +export class TaskAttentionProjection { + readonly #options: TaskAttentionProjectionOptions + readonly #changed: Emitter + readonly #subscriptions: { dispose: () => void }[] + readonly #states = new Map() + readonly onDidChange: Emitter['event'] + #marks: readonly LocalTaskMark[] = [] + #health: 'starting' | 'ready' | 'degraded' | 'stopped' = 'starting' + #revision = 0 + + constructor(options: TaskAttentionProjectionOptions) { + this.#options = options + this.#changed = new Emitter(options.onError) + this.onDidChange = this.#changed.event + this.#subscriptions = [ + options.marks.onDidCommit(event => this.#consume(() => this.#refresh(event.kind === 'attention' ? [event.conversationId] : event.kind === 'deleted' ? event.conversationIds : []))), + options.eventLog.onDidCommit((event) => { + if (event.type !== 'run.completed' && event.type !== 'run.failed') + return + this.#consume(() => { + const run = options.runs.findById(event.runId) + if (run && run.purpose !== 'conversation.compaction') + this.#refresh([run.conversationId]) + }) + }), + ] + } + + get state() { + return this.#health + } + + start(deletions: Pick, reconciliations?: Pick): void { + this.#subscriptions.push(deletions.onDidCommit(event => this.#consume(() => this.#refresh([event.conversationId])))) + if (reconciliations) + this.#subscriptions.push(reconciliations.onDidReconcile(event => this.#consume(() => this.#refresh([event.conversationId])))) + this.reconcile() + } + + reconcile(): void { + if (this.#health === 'stopped') + return + try { + this.#refresh() + this.#health = 'ready' + } + catch (error) { + this.#health = 'degraded' + throw error + } + } + + list(): readonly LocalTaskMark[] { + this.#requireReady() + return this.#marks + } + + states(conversationIds: readonly string[]): LocalTaskMarkState[] { + this.#requireReady() + try { + this.#refresh(conversationIds) + } + catch (error) { + this.#health = 'degraded' + throw error + } + return conversationIds.flatMap(id => this.#states.get(id) ?? []) + } + + dispose(): void { + this.#health = 'stopped' + for (const subscription of this.#subscriptions) + subscription.dispose() + this.#changed.dispose() + this.#states.clear() + this.#marks = [] + } + + #requireReady(): void { + if (this.#health === 'stopped') + throw new Error('Task attention projection is stopped') + if (this.#health !== 'ready') + this.reconcile() + } + + #consume(operation: () => void): void { + if (this.#health === 'stopped') + return + try { + if (this.#health !== 'ready') + this.reconcile() + else operation() + } + catch (error) { + this.#health = 'degraded' + this.#options.onError(error) + } + } + + #refresh(conversationIds?: readonly string[]): void { + const rows = conversationIds ? this.#options.repository.states(conversationIds) : this.#options.repository.allStates() + const states = new Map(rows.map(state => [state.conversationId, copyEventSnapshot(state)])) + const changed: string[] = [] + const targets = conversationIds ?? [...new Set([...this.#states.keys(), ...states.keys()])] + const marks = copyEventSnapshot(this.#options.repository.list()) + const marksChanged = JSON.stringify(this.#marks) !== JSON.stringify(marks) + for (const id of targets) { + const next = states.get(id) + if (JSON.stringify(this.#states.get(id)) === JSON.stringify(next)) + continue + changed.push(id) + if (next) + this.#states.set(id, next) + else this.#states.delete(id) + } + this.#marks = marks + if (changed.length || marksChanged) { + this.#revision += 1 + this.#changed.fire(copyEventSnapshot({ revision: this.#revision, conversationIds: changed, marksChanged })) + } + } +} diff --git a/apps/buddy/service/src/conversations/TaskMarkService.ts b/apps/buddy/service/src/conversations/TaskMarkService.ts new file mode 100644 index 00000000..48e9b993 --- /dev/null +++ b/apps/buddy/service/src/conversations/TaskMarkService.ts @@ -0,0 +1,94 @@ +import type { LocalTaskMarkState, TaskMarkClearInput, TaskMarkInput, TaskMarkReadInput } from '../../../shared/conversation/taskMarkApi' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { TaskMarkRepository } from '../storage/taskMarkRepository' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { BuddyServiceError } from '../rpc/runtimeRequest' + +export type TaskMarkCommit = EventSnapshot<{ commitId: string } & ( + | { kind: 'created' | 'updated', markId: string } + | { kind: 'deleted', markId: string, conversationIds: string[] } + | { kind: 'attention', conversationId: string, assignmentChanged: boolean, readChanged: boolean, state: LocalTaskMarkState } +)> + +export class TaskMarkService { + readonly #repository: TaskMarkRepository + readonly #committed: Emitter + readonly onDidCommit: Emitter['event'] + #disposed = false + + constructor(repository: TaskMarkRepository, onObserverError: (error: unknown) => void = () => {}) { + this.#repository = repository + this.#committed = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + } + + create(input: TaskMarkInput) { + this.#requireActive() + const mark = this.#repository.create(input) + this.#committed.fire(copyEventSnapshot({ commitId: randomUUID(), kind: 'created', markId: mark.id })) + return mark + } + + update(id: string, input: TaskMarkInput) { + this.#requireActive() + const current = this.#repository.list().find(mark => mark.id === id) + if (current?.name === input.name && current.description === input.description && current.color === input.color) + return current + const mark = this.#repository.update(id, input) + this.#committed.fire(copyEventSnapshot({ commitId: randomUUID(), kind: 'updated', markId: mark.id })) + return mark + } + + delete(id: string): boolean { + this.#requireActive() + const result = this.#repository.deleteWithReceipt(id) + if (result.deleted) + this.#committed.fire(copyEventSnapshot({ commitId: randomUUID(), kind: 'deleted', markId: id, conversationIds: result.conversationIds })) + return result.deleted + } + + assign(conversationId: string, markId: string | null): LocalTaskMarkState { + this.#requireActive() + const before = this.#repository.getState(conversationId) + if (before.markId === markId) + return before + const state = this.#repository.assign(conversationId, markId) + this.#publishAttention(before, state) + return state + } + + setRead(input: TaskMarkReadInput): LocalTaskMarkState { + this.#requireActive() + const before = this.#repository.getState(input.conversationId) + const state = this.#repository.setRead(input) + this.#publishAttention(before, state) + return state + } + + clear(input: TaskMarkClearInput): LocalTaskMarkState { + this.#requireActive() + const before = this.#repository.getState(input.conversationId) + const state = this.#repository.clear(input) + this.#publishAttention(before, state) + return state + } + + dispose(): void { + this.#disposed = true + this.#committed.dispose() + } + + #requireActive(): void { + if (this.#disposed) + throw new BuddyServiceError('VALIDATION_FAILED') + } + + #publishAttention(before: LocalTaskMarkState, state: LocalTaskMarkState): void { + const assignmentChanged = before.markId !== state.markId + const readChanged = before.readRevision !== state.readRevision + if (assignmentChanged || readChanged) + this.#committed.fire(copyEventSnapshot({ commitId: randomUUID(), kind: 'attention', conversationId: state.conversationId, assignmentChanged, readChanged, state })) + } +} diff --git a/apps/buddy/service/src/conversations/__tests__/ConversationLifecycleService.spec.ts b/apps/buddy/service/src/conversations/__tests__/ConversationLifecycleService.spec.ts index 88896ff3..3c097e9d 100644 --- a/apps/buddy/service/src/conversations/__tests__/ConversationLifecycleService.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/ConversationLifecycleService.spec.ts @@ -5,11 +5,14 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { createRunEventLog } from '../../events/createRunEventLog' +import { RunLifecycleService } from '../../runs/RunLifecycleService' import { prepareTestTurnRequest } from '../../storage/__tests__/composerDraftTestFixture' import { createAttachmentRepository } from '../../storage/attachmentRepository' +import { createConversationDeletionRepository } from '../../storage/conversationDeletionRepository' import { createConversationDirectoryGrantRepository } from '../../storage/conversationDirectoryGrantRepository' import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' import { ConversationLifecycleService } from '../ConversationLifecycleService' const databases: DatabaseSync[] = [] @@ -22,6 +25,75 @@ afterEach(async () => { }) describe('conversationLifecycleService', () => { + it('rolls back hiding when grant revocation fails in the same transaction', async () => { + const f = deletionFixture() + f.database.exec(`CREATE TRIGGER reject_grant_revocation BEFORE UPDATE ON conversation_directory_grants + BEGIN SELECT RAISE(ABORT, 'revoke failed'); END;`) + const commits: unknown[] = [] + f.service.onDidCommit(event => commits.push(event)) + await expect(f.service.delete('task-1')).rejects.toThrow('revoke failed') + expect(f.conversations.findById('task-1')?.deletedAt).toBeNull() + expect(f.grants.listActive('task-1')).toHaveLength(1) + expect(commits).toEqual([]) + }) + + it('coalesces cleanup and retries an existing tombstone without another deletion commit', async () => { + const f = deletionFixture() + const gate = Promise.withResolvers() + f.cancel.mockImplementationOnce(() => gate.promise) + f.invalidate.mockResolvedValueOnce({ pending: 1, degraded: 0 }) + const commits: unknown[] = [] + const cleanup: string[] = [] + f.service.onDidCommit(event => commits.push(event)) + f.service.onDidCleanup(event => cleanup.push(event.status)) + const first = f.service.delete('task-1') + expect(f.service.delete('task-1')).toBe(first) + expect(f.conversations.findById('task-1')?.deletedAt).not.toBeNull() + expect(f.grants.listActive('task-1')).toEqual([]) + gate.resolve(0) + await expect(first).rejects.toMatchObject({ code: 'CONVERSATION_CLEANUP_PENDING' }) + await expect(f.service.delete('task-1')).resolves.toBe(false) + expect(commits).toHaveLength(1) + expect(cleanup).toEqual(['started', 'failed', 'started', 'completed']) + await f.service.delete('task-1') + expect(cleanup).toHaveLength(4) + }) + + it('recovers legacy tombstones with active grants and blocks a late grant', async () => { + const f = deletionFixture() + f.conversations.markDeleted('task-1', '2026-09-28T00:00:01.000Z') + const commits: unknown[] = [] + f.service.onDidCommit(event => commits.push(event)) + expect(await f.service.recoverPendingDeletions()).toBe(1) + expect(f.grants.listActive('task-1')).toEqual([]) + expect(commits).toEqual([expect.objectContaining({ tombstoned: false, revokedGrantIds: ['grant-1'] })]) + expect(() => f.grants.grant({ id: 'grant-late', conversationId: 'task-1', root: '/late', canonicalRoot: '/late', createdAt: new Date().toISOString() })) + .toThrow(expect.objectContaining({ code: 'DIRECTORY_GRANT_OWNER_INVALID' })) + expect(await f.service.recoverPendingDeletions()).toBe(0) + }) + + it('prevents a prepared run from entering execution after its task is tombstoned', async () => { + const f = deletionFixture() + f.conversations.createMessage({ id: 'message-1', conversationId: 'task-1', branchId: 'branch-1', runId: null, role: 'user', content: { text: 'Test' }, createdAt: '2026-09-28T00:00:00.000Z' }) + const runs = createRunRepository(f.database) + runs.create({ id: 'run-late', conversationId: 'task-1', branchId: 'branch-1', triggeringMessageId: 'message-1', provider: 'fixture', model: 'fixture', piSessionFile: null, purpose: 'chat', status: 'queued', startedAt: '2026-09-28T00:00:00.000Z', approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + const root = await mkdtemp(join(tmpdir(), 'lexora-delete-queued-')) + directories.push(root) + const eventLog = createRunEventLog({ conversationsDirectory: root, database: f.database }) + const lifecycle = new RunLifecycleService({ eventLog, repository: runs }) + f.cancelQueued.mockImplementation(async (conversationId) => { + for (const run of runs.listIncomplete()) { + if (run.conversationId === conversationId && run.status === 'queued') + await lifecycle.finalize({ runId: run.id, status: 'cancelled', errorCode: 'RUN_CANCELLED', completedAt: new Date().toISOString() }) + } + }) + await f.service.delete('task-1') + expect(runs.markRunning('run-late', '2026-09-28T00:00:01.000Z')).toBe(false) + expect(runs.findById('run-late')?.status).toBe('cancelled') + expect((await eventLog.list('run-late')).map(event => event.type)).toEqual(['run.cancelled']) + await eventLog.close() + }) + it('hides the conversation while preserving product history and usage-owned files', async () => { const root = await mkdtemp(join(tmpdir(), 'lexora-buddy-conversation-')) directories.push(root) @@ -86,11 +158,12 @@ describe('conversationLifecycleService', () => { await mkdir(sessionDirectory, { recursive: true }) await writeFile(join(sessionDirectory, 'session.jsonl'), '{}\n') const cancelled = vi.fn(async () => 1) - const invalidated = vi.fn(async () => 1) + const invalidated = vi.fn(async () => ({ pending: 0, degraded: 0 })) const service = new ConversationLifecycleService({ conversations, - directoryGrants, + deletion: createConversationDeletionRepository(database), runner: { cancelAndWaitForConversation: cancelled }, + cancelQueuedRuns: async () => {}, sessions: { invalidateConversation: invalidated }, }) @@ -135,15 +208,16 @@ describe('conversationLifecycleService', () => { id: 'grant-1', root: '/external', }) - const invalidated = vi.fn(async () => 0) + const invalidated = vi.fn(async () => ({ pending: 0, degraded: 0 })) const service = new ConversationLifecycleService({ conversations, - directoryGrants, + deletion: createConversationDeletionRepository(database), runner: { cancelAndWaitForConversation: vi.fn(async () => { throw new Error('cancellation failed') }), }, + cancelQueuedRuns: async () => {}, sessions: { invalidateConversation: invalidated }, }) @@ -154,3 +228,17 @@ describe('conversationLifecycleService', () => { expect(invalidated).not.toHaveBeenCalled() }) }) + +function deletionFixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const conversations = createConversationRepository(database) + conversations.create({ id: 'task-1', branchId: 'branch-1', spaceId: null, title: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: '2026-09-28T00:00:00.000Z' }) + const grants = createConversationDirectoryGrantRepository(database) + grants.grant({ id: 'grant-1', conversationId: 'task-1', root: '/external', canonicalRoot: '/external', createdAt: '2026-09-28T00:00:00.000Z' }) + const cancel = vi.fn(async () => 0) + const cancelQueued = vi.fn(async (_conversationId: string) => {}) + const invalidate = vi.fn(async () => ({ pending: 0, degraded: 0 })) + const service = new ConversationLifecycleService({ conversations, deletion: createConversationDeletionRepository(database), runner: { cancelAndWaitForConversation: cancel }, cancelQueuedRuns: cancelQueued, sessions: { invalidateConversation: invalidate } }) + return { database, conversations, grants, service, cancel, cancelQueued, invalidate } +} diff --git a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts new file mode 100644 index 00000000..1ec0bfc6 --- /dev/null +++ b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts @@ -0,0 +1,76 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ConversationMetadataCommit } from '../ConversationMetadataService' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { ConversationMetadataService } from '../ConversationMetadataService' + +const databases: DatabaseSync[] = [] +afterEach(() => { + for (const database of databases.splice(0)) + database.close() +}) + +describe('conversation metadata ownership', () => { + it('publishes the actual title CAS commits and protects event snapshots', () => { + const f = fixture() + const changes: ConversationMetadataCommit[] = [] + f.service.onDidCommit(event => changes.push(event)) + expect(f.service.renameGenerated({ id: 'task-1', title: 'Generated', expectedRevision: 0 })).not.toBeNull() + f.service.rename({ id: 'task-1', title: 'Generated' }) + expect(f.service.renameGenerated({ id: 'task-1', title: 'Late generated', expectedRevision: 1 })).toBeNull() + expect(changes.map(event => [event.kind, event.titleSource, event.titleRevision])).toEqual([ + ['title', 'generated', 1], + ['title', 'manual', 2], + ]) + expect(Reflect.set(changes[0]!.conversation, 'title', 'External mutation')).toBe(false) + expect(f.repository.getTitleState('task-1')).toMatchObject({ title: 'Generated', source: 'manual', revision: 2 }) + expect(f.repository.findById('task-1')?.updatedAt).toBe('2026-09-28T00:00:00.000Z') + }) + + it('keeps a committed permission change when its required invalidation fails and retries only application', async () => { + const f = fixture() + const changes: ConversationMetadataCommit[] = [] + f.service.onDidCommit(event => changes.push(event)) + f.invalidate.mockRejectedValueOnce(new Error('Session cleanup unavailable')) + const input = { id: 'task-1', approvalPolicy: 'manual' as const, executionProfile: 'workspace_write' as const } + await expect(f.service.setPermissionSettings(input)).rejects.toThrow('Session cleanup unavailable') + expect(f.repository.findById('task-1')?.approvalPolicy).toBe('manual') + expect(changes.map(event => event.kind)).toEqual(['permissions']) + await expect(f.service.setPermissionSettings(input)).resolves.toMatchObject({ approvalPolicy: 'manual' }) + expect(changes).toHaveLength(1) + expect(f.invalidate).toHaveBeenCalledTimes(2) + await f.service.setPermissionSettings(input) + expect(f.invalidate).toHaveBeenCalledTimes(2) + }) + + it('rechecks the captured task after model resolution without publishing a no-op', async () => { + const f = fixture() + const selection = { providerId: 'provider-1', modelId: 'model-1', reasoning: null, serviceTier: null } + const changes: ConversationMetadataCommit[] = [] + f.service.onDidCommit(event => changes.push(event)) + await f.service.setModelSelection({ id: 'task-1', modelSelection: selection }) + await f.service.setModelSelection({ id: 'task-1', modelSelection: selection }) + expect(changes).toHaveLength(1) + const gate = Promise.withResolvers() + f.resolveModel.mockImplementationOnce(() => gate.promise) + const operation = f.service.setModelSelection({ id: 'task-1', modelSelection: { ...selection, modelId: 'model-2' } }) + f.repository.markDeleted('task-1', new Date().toISOString()) + gate.resolve({ ...selection, modelId: 'model-2' }) + await expect(operation).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(f.repository.findById('task-1')?.modelSelection?.modelId).toBe('model-1') + expect(changes).toHaveLength(1) + }) +}) + +function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const repository = createConversationRepository(database) + repository.create({ id: 'task-1', branchId: 'branch-1', spaceId: null, title: 'Original', approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: '2026-09-28T00:00:00.000Z' }) + database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'task-1') + const invalidate = vi.fn(async (_id: string) => ({ pending: 0, degraded: 0 })) + const resolveModel = vi.fn(async (selection: NonNullable>['modelSelection']) => selection!) + const service = new ConversationMetadataService({ repository, resolveModelSelection: resolveModel, sessions: { invalidateConversation: invalidate } }) + return { service, repository, invalidate, resolveModel } +} diff --git a/apps/buddy/service/src/conversations/__tests__/TaskAttentionProjection.spec.ts b/apps/buddy/service/src/conversations/__tests__/TaskAttentionProjection.spec.ts new file mode 100644 index 00000000..fb0ed3b0 --- /dev/null +++ b/apps/buddy/service/src/conversations/__tests__/TaskAttentionProjection.spec.ts @@ -0,0 +1,160 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { RunSqlReconciliation } from '../../runs/RunLifecycleService' +import type { TaskAttentionChange } from '../TaskAttentionProjection' +import type { TaskMarkCommit } from '../TaskMarkService' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createRunEventLog } from '../../events/createRunEventLog' +import { RunEventProjectionError } from '../../events/RunEventFailure' +import { RunLifecycleService } from '../../runs/RunLifecycleService' +import { createConversationDeletionRepository } from '../../storage/conversationDeletionRepository' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' +import { createTaskMarkRepository } from '../../storage/taskMarkRepository' +import { ConversationLifecycleService } from '../ConversationLifecycleService' +import { TaskAttentionProjection } from '../TaskAttentionProjection' +import { TaskMarkService } from '../TaskMarkService' + +const databases: DatabaseSync[] = [] +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const stop of cleanup.splice(0)) + await stop() + for (const database of databases.splice(0)) + database.close() +}) + +describe('task mark commits and effective attention', () => { + it('reacts to actual SQL fallback without presenting a failed durable projection as a terminal commit', async () => { + const f = await fixture() + const events: RunSqlReconciliation[] = [] + const changes: TaskAttentionChange[] = [] + f.lifecycle.onDidReconcile(event => events.push(event)) + f.attention.onDidChange(event => changes.push(event)) + f.start('sql-only') + vi.spyOn(f.eventLog, 'append').mockRejectedValueOnce(new Error('Nonfatal fixture append refusal')) + await f.lifecycle.finalize({ runId: 'sql-only', status: 'completed', errorCode: null, completedAt: '2026-09-28T00:01:00.000Z' }) + expect(events).toMatchObject([{ runId: 'sql-only', status: 'failed', errorCode: 'EVENT_LOG_FAILED' }]) + expect(changes.at(-1)?.conversationIds).toEqual(['a']) + expect(f.attention.states(['a'])[0]).toMatchObject({ resultRunId: 'sql-only', unread: true }) + expect(f.eventLog.listForRuns(['sql-only'])).toEqual([]) + f.start('fatal') + vi.spyOn(f.eventLog, 'append').mockRejectedValueOnce(new RunEventProjectionError('fatal', [])) + await expect(f.lifecycle.finalize({ runId: 'fatal', status: 'completed', errorCode: null, completedAt: '2026-09-28T00:02:00.000Z' })).rejects.toBeInstanceOf(RunEventProjectionError) + expect(f.runs.findById('fatal')?.status).toBe('running') + expect(events).toHaveLength(1) + expect(f.attention.states(['a'])[0]?.resultRunId).toBe('sql-only') + }) + + it('observes new durable results independently of readRevision and distinguishes stale clear from read', async () => { + const f = await fixture() + const commits: TaskMarkCommit[] = [] + const changes: TaskAttentionChange[] = [] + f.marks.onDidCommit(event => commits.push(event)) + f.attention.onDidChange(event => changes.push(event)) + const mark = f.marks.create({ name: 'Review', description: '', color: '#3979d6' }) + f.marks.assign('a', mark.id) + await f.complete('result-1') + const first = f.attention.states(['a'])[0]! + const read = f.marks.setRead({ ...first, read: true }) + await f.complete('result-2') + const second = f.attention.states(['a'])[0]! + expect(second).toMatchObject({ resultRunId: 'result-2', readRevision: read.readRevision, unread: true }) + const cleared = f.marks.clear(read) + expect(cleared).toMatchObject({ markId: null, unread: true, readRevision: read.readRevision, resultRunId: 'result-2' }) + expect(commits.at(-1)).toMatchObject({ kind: 'attention', assignmentChanged: true, readChanged: false }) + expect(f.attention.list()[0]?.taskCount).toBe(0) + expect(changes.filter(event => event.conversationIds.includes('a')).length).toBeGreaterThanOrEqual(5) + expect(Reflect.set(second, 'unread', false)).toBe(false) + const count = commits.length + f.marks.setRead({ ...read, read: true }) + expect(commits).toHaveLength(count) + }) + + it('captures all foreign-key assignment removals and excludes tombstoned tasks from counts', async () => { + const f = await fixture() + const commits: TaskMarkCommit[] = [] + f.marks.onDidCommit(event => commits.push(event)) + const input = { name: 'Review', description: '', color: '#3979d6' } + const mark = f.marks.create(input) + f.marks.assign('a', mark.id) + f.marks.assign('b', mark.id) + f.marks.update(mark.id, input) + expect(commits.filter(event => event.kind === 'updated')).toEqual([]) + expect(f.attention.list()[0]?.taskCount).toBe(2) + f.marks.delete(mark.id) + expect(commits.at(-1)).toMatchObject({ kind: 'deleted', conversationIds: ['a', 'b'] }) + expect(f.attention.states(['a', 'b']).map(state => state.markId)).toEqual([null, null]) + const next = f.marks.create(input) + f.marks.assign('a', next.id) + await f.deletion.delete('a') + expect(f.attention.list()[0]?.taskCount).toBe(0) + expect(f.attention.states(['a', 'b']).map(state => state.conversationId)).toEqual(['b']) + }) + + it('retains a committed assignment when a consumer fails and rebuilds before serving degraded reads', async () => { + const f = await fixture() + const mark = f.marks.create({ name: 'Review', description: '', color: '#3979d6' }) + vi.spyOn(f.repository, 'states').mockImplementationOnce(() => { + throw new Error('Projection read failed') + }) + expect(f.marks.assign('a', mark.id)).toMatchObject({ markId: mark.id }) + expect(f.attention.state).toBe('degraded') + expect(f.errors).toHaveBeenCalledTimes(1) + expect(f.attention.list()[0]?.taskCount).toBe(1) + expect(f.attention.state).toBe('ready') + expect(f.attention.states(['a'])[0]?.markId).toBe(mark.id) + const changes: TaskAttentionChange[] = [] + f.attention.onDidChange(event => changes.push(event)) + f.attention.dispose() + f.marks.assign('a', null) + expect(changes).toEqual([]) + expect(f.attention.state).toBe('stopped') + }) +}) + +async function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const root = await mkdtemp(join(tmpdir(), 'lexora-task-attention-')) + const conversations = createConversationRepository(database) + const now = '2026-09-28T00:00:00.000Z' + for (const id of ['a', 'b']) { + conversations.create({ id, branchId: `branch-${id}`, title: id, createdAt: now, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + conversations.createMessage({ id: `question-${id}`, branchId: `branch-${id}`, conversationId: id, runId: null, role: 'user', content: { text: 'Test' }, createdAt: now }) + } + const runs = createRunRepository(database) + const repository = createTaskMarkRepository(database) + const marks = new TaskMarkService(repository) + const eventLog = createRunEventLog({ conversationsDirectory: join(root, 'conversations'), database }) + const deletion = new ConversationLifecycleService({ + conversations, + deletion: createConversationDeletionRepository(database), + runner: { cancelAndWaitForConversation: async () => 0 }, + cancelQueuedRuns: async () => {}, + sessions: { invalidateConversation: async () => ({ pending: 0, degraded: 0 }) }, + }) + const errors = vi.fn() + const attention = new TaskAttentionProjection({ marks, eventLog, runs, repository, onError: errors }) + const lifecycle = new RunLifecycleService({ eventLog, repository: runs }) + attention.start(deletion, lifecycle) + cleanup.push(async () => { + attention.dispose() + marks.dispose() + await deletion.dispose() + await lifecycle.dispose() + await eventLog.close() + await rm(root, { recursive: true, force: true }) + }) + function start(id: string) { + runs.create({ id, branchId: 'branch-a', conversationId: 'a', triggeringMessageId: 'question-a', provider: 'fixture', model: 'fixture', piSessionFile: null, purpose: 'chat', status: 'running', startedAt: now, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + } + async function complete(id: string) { + start(id) + await eventLog.append({ runId: id, type: 'run.completed', payload: {} }) + } + return { marks, attention, complete, deletion, repository, errors, start, eventLog, lifecycle, runs } +} diff --git a/apps/buddy/service/src/conversations/__tests__/registerConversationRpc.spec.ts b/apps/buddy/service/src/conversations/__tests__/registerConversationRpc.spec.ts index b65a904d..997cc8f3 100644 --- a/apps/buddy/service/src/conversations/__tests__/registerConversationRpc.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/registerConversationRpc.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' import { createRunRepository } from '../../storage/runRepository' +import { ConversationMetadataService } from '../ConversationMetadataService' import { registerConversationRpc } from '../registerConversationRpc' const databases: DatabaseSync[] = [] @@ -45,17 +46,17 @@ describe('registerConversationRpc', () => { effects.push(`delete:${conversationId}`) return conversations.markDeleted(conversationId, '2026-08-27T00:05:00.000Z') }, - resolveModelSelection: async selection => ({ - ...selection, - contextWindow: 128_000, - maxTokens: 16_384, - }), rpc: harness.rpc, - sessions: { - async invalidateConversation(conversationId) { - effects.push(`session:${conversationId}`) + metadata: new ConversationMetadataService({ + repository: conversations, + resolveModelSelection: async selection => ({ ...selection, contextWindow: 128_000, maxTokens: 16_384 }), + sessions: { + async invalidateConversation(conversationId) { + effects.push(`session:${conversationId}`) + return { pending: 0, degraded: 0 } + }, }, - }, + }), }) await expect(harness.invoke('conversations.list', {})) @@ -301,10 +302,9 @@ function emptyConversationDependencies( deleteConversation: () => Promise.resolve(false), eventLog: { listForRuns: () => [] }, isDeleting: () => false, - resolveModelSelection: selection => Promise.resolve(selection), + metadata: new ConversationMetadataService({ repository: conversations, resolveModelSelection: selection => Promise.resolve(selection), sessions: { invalidateConversation: () => Promise.resolve({ pending: 0, degraded: 0 }) } }), runInputs: { findByRunId: () => null }, runs: { listForTimeline: () => [] }, - sessions: { invalidateConversation: () => Promise.resolve() }, } } diff --git a/apps/buddy/service/src/conversations/__tests__/registerTaskMarkRpc.spec.ts b/apps/buddy/service/src/conversations/__tests__/registerTaskMarkRpc.spec.ts index 12206c97..5ddb355c 100644 --- a/apps/buddy/service/src/conversations/__tests__/registerTaskMarkRpc.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/registerTaskMarkRpc.spec.ts @@ -5,6 +5,7 @@ import { createConversationRepository } from '../../storage/conversationReposito import { openBuddyDatabase } from '../../storage/database' import { createTaskMarkRepository } from '../../storage/taskMarkRepository' import { registerTaskMarkRpc } from '../registerTaskMarkRpc' +import { TaskMarkService } from '../TaskMarkService' describe('task mark RPC boundary', () => { it('clears a task through a strict versioned request without deleting its mark definition', async () => { @@ -14,7 +15,7 @@ describe('task mark RPC boundary', () => { const stop = registerTaskMarkRpc({ onRequest: (method, handler) => { handlers.set(method, handler) return () => handlers.delete(method) - } }, repository) + } }, new TaskMarkService(repository), repository) try { createConversationRepository(database).create({ id: 'a', branchId: 'branch-a', title: '测试', createdAt: '2026-09-12T00:00:00.000Z', spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) const mark = repository.create({ name: '待检查', description: '', color: '#3979d6' }) @@ -37,10 +38,11 @@ describe('task mark RPC boundary', () => { it('validates exact character limits and color syntax, allows duplicate names and rejects system mutations', async () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) const handlers = new Map() + const repository = createTaskMarkRepository(database) const stop = registerTaskMarkRpc({ onRequest: (method, handler) => { handlers.set(method, handler) return () => handlers.delete(method) - } }, createTaskMarkRepository(database)) + } }, new TaskMarkService(repository), repository) const invoke = async (method: string, input: unknown) => handlers.get(method)!(input) const valid = { name: '字'.repeat(20), description: '说'.repeat(200), color: '#AABBCC' } try { diff --git a/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts b/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts new file mode 100644 index 00000000..fb35e8f8 --- /dev/null +++ b/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts @@ -0,0 +1,18 @@ +import type { ConversationMetadataService } from './ConversationMetadataService' +import { extensionAgentHandler } from '../plugins/extensionAgentHandlers' + +export function createExtensionTaskCapabilities(conversations: Pick) { + return { + 'task.get': extensionAgentHandler('task.get', (_input, context) => { + const title = conversations.getTitleState(context.conversationId) + if (!title) + throw new Error('EXTENSION_TASK_UNAVAILABLE') + return { id: context.conversationId, title: title.title, titleSource: title.source, titleRevision: title.revision } + }), + 'task.rename': extensionAgentHandler('task.rename', (input, context) => { + context.signal.throwIfAborted() + const result = conversations.renameGenerated({ id: context.conversationId, ...input }) + return { applied: !!result } + }), + } +} diff --git a/apps/buddy/service/src/conversations/registerConversationRpc.ts b/apps/buddy/service/src/conversations/registerConversationRpc.ts index 01680250..7e38a938 100644 --- a/apps/buddy/service/src/conversations/registerConversationRpc.ts +++ b/apps/buddy/service/src/conversations/registerConversationRpc.ts @@ -7,11 +7,11 @@ import type { } from '../storage/artifactRepository' import type { ConversationHistoryRepository } from '../storage/conversationHistoryRepository' import type { ConversationIndexRepository } from '../storage/conversationIndexRepository' -import type { ConversationModelSelection } from '../storage/conversationRecord' import type { ConversationRepository } from '../storage/conversationRepository' import type { ConversationTimelineRepository } from '../storage/conversationTimelineRepository' import type { RunInputRepository } from '../storage/runInputRepository' import type { RunRepository } from '../storage/runRepository' +import type { ConversationMetadataService } from './ConversationMetadataService' import { conversationsRpc } from '../../../shared/conversation/conversationApi' import { toPublicRunEvent } from '../../../shared/runs/publicRunEvent' @@ -30,17 +30,9 @@ import { } from './messagePageCursor' import { projectRunOutputs } from './projectRunOutputs' -export interface ConversationSessionInvalidator { - invalidateConversation: (conversationId: string) => Promise -} - type ConversationRpcRepository = Pick< ConversationRepository, - | 'activateBranch' | 'findById' - | 'rename' - | 'setPermissionSettings' - | 'setModelSelection' > & Pick< ConversationHistoryRepository, 'listBranches' | 'listMessagePage' @@ -51,16 +43,13 @@ export interface RegisterConversationRpcOptions { attachments: Pick changes: Pick conversations: ConversationRpcRepository + metadata: Pick deleteConversation: (conversationId: string) => Promise eventLog: { listForRuns: (runIds: readonly string[]) => BuddyRunEvent[] } isDeleting: (conversationId: string) => boolean - resolveModelSelection: ( - selection: ConversationModelSelection, - ) => Promise rpc: RuntimeRequestRegistrar runInputs: Pick runs: Pick - sessions: ConversationSessionInvalidator } export function registerConversationRpc(options: RegisterConversationRpcOptions): () => void { @@ -73,53 +62,29 @@ export function registerConversationRpc(options: RegisterConversationRpcOptions) return requireActiveConversation(options, input.conversationId) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.rename, (input) => { - return options.conversations.rename({ + return options.metadata.rename({ id: input.conversationId, title: input.title, - updatedAt: new Date().toISOString(), }) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.setPermissionSettings, async (input) => { - const current = requireActiveConversation(options, input.conversationId) - if ( - current.approvalPolicy === input.approvalPolicy - && current.executionProfile === input.executionProfile - ) { - return current - } - const conversation = options.conversations.setPermissionSettings({ + return options.metadata.setPermissionSettings({ approvalPolicy: input.approvalPolicy, executionProfile: input.executionProfile, id: input.conversationId, - updatedAt: new Date().toISOString(), }) - if (!conversation) - throw new BuddyServiceError('VALIDATION_FAILED') - await options.sessions.invalidateConversation(input.conversationId) - return conversation })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.setModelSelection, async (input) => { - requireActiveConversation(options, input.conversationId) - const selection = await options.resolveModelSelection(input.modelSelection) - return requireValue(options.conversations.setModelSelection({ + return options.metadata.setModelSelection({ id: input.conversationId, - modelSelection: { - modelId: selection.modelId, - providerId: selection.providerId, - reasoning: selection.reasoning, - serviceTier: selection.serviceTier, - }, - updatedAt: new Date().toISOString(), - })) + modelSelection: input.modelSelection, + }) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.delete, async (input) => { return options.deleteConversation(input.conversationId) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.activateBranch, (input) => { - return options.conversations.activateBranch({ - ...input, - updatedAt: new Date().toISOString(), - }) + return options.metadata.activateBranch(input) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.listBranches, (input) => { requireValue(options.conversations.findById(input.conversationId)) diff --git a/apps/buddy/service/src/conversations/registerConversationTreeRpc.ts b/apps/buddy/service/src/conversations/registerConversationTreeRpc.ts index 89e5eb6b..5fce14f8 100644 --- a/apps/buddy/service/src/conversations/registerConversationTreeRpc.ts +++ b/apps/buddy/service/src/conversations/registerConversationTreeRpc.ts @@ -7,16 +7,16 @@ import { toPublicRunEvent } from '../../../shared/runs/publicRunEvent' import { withMessageAttachments } from '../attachments/publicAttachment' import { BuddyServiceError, registerRuntimeRequest } from '../rpc/runtimeRequest' import { toPublicRun } from '../runs/publicRun' -import { createConversationTreeRepository } from '../storage/conversationTreeRepository' +import { createConversationTreeReader } from '../storage/conversationTreeRepository' import { projectConversationTree } from './projectConversationTree' import { projectRunOutputs } from './projectRunOutputs' export function registerConversationTreeRpc(options: Pick & { database: DatabaseSync - conversations: ConversationRepository + conversations: Pick runs: Pick }) { - const repository = createConversationTreeRepository(options.database) + const repository = createConversationTreeReader(options.database) function requireConversation(conversationId: string) { const conversation = options.conversations.findById(conversationId) if (!conversation || conversation.deletedAt) diff --git a/apps/buddy/service/src/conversations/registerTaskMarkRpc.ts b/apps/buddy/service/src/conversations/registerTaskMarkRpc.ts index 6e8e641f..37223eb3 100644 --- a/apps/buddy/service/src/conversations/registerTaskMarkRpc.ts +++ b/apps/buddy/service/src/conversations/registerTaskMarkRpc.ts @@ -1,18 +1,19 @@ import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' -import type { TaskMarkRepository } from '../storage/taskMarkRepository' +import type { TaskAttentionProjection } from './TaskAttentionProjection' +import type { TaskMarkService } from './TaskMarkService' import { taskMarksRpc } from '../../../shared/conversation/taskMarkApi' import { registerRuntimeRequest } from '../rpc/runtimeRequest' -export function registerTaskMarkRpc(rpc: RuntimeRequestRegistrar, repository: TaskMarkRepository): () => void { +export function registerTaskMarkRpc(rpc: RuntimeRequestRegistrar, service: Pick, attention: Pick): () => void { const disposers = [ - registerRuntimeRequest(rpc, taskMarksRpc.list, () => repository.list()), - registerRuntimeRequest(rpc, taskMarksRpc.create, input => repository.create(input)), - registerRuntimeRequest(rpc, taskMarksRpc.update, input => repository.update(input.id, input)), - registerRuntimeRequest(rpc, taskMarksRpc.delete, input => repository.delete(input.id)), - registerRuntimeRequest(rpc, taskMarksRpc.states, input => repository.states(input.conversationIds)), - registerRuntimeRequest(rpc, taskMarksRpc.assign, input => repository.assign(input.conversationId, input.markId)), - registerRuntimeRequest(rpc, taskMarksRpc.setRead, input => repository.setRead(input)), - registerRuntimeRequest(rpc, taskMarksRpc.clear, input => repository.clear(input)), + registerRuntimeRequest(rpc, taskMarksRpc.list, () => attention.list()), + registerRuntimeRequest(rpc, taskMarksRpc.create, input => service.create(input)), + registerRuntimeRequest(rpc, taskMarksRpc.update, input => service.update(input.id, input)), + registerRuntimeRequest(rpc, taskMarksRpc.delete, input => service.delete(input.id)), + registerRuntimeRequest(rpc, taskMarksRpc.states, input => attention.states(input.conversationIds)), + registerRuntimeRequest(rpc, taskMarksRpc.assign, input => service.assign(input.conversationId, input.markId)), + registerRuntimeRequest(rpc, taskMarksRpc.setRead, input => service.setRead(input)), + registerRuntimeRequest(rpc, taskMarksRpc.clear, input => service.clear(input)), ] return () => disposers.forEach(dispose => dispose()) } diff --git a/apps/buddy/service/src/createBuddyCapabilityFactory.ts b/apps/buddy/service/src/createBuddyCapabilityFactory.ts index ec5e3705..f1df1729 100644 --- a/apps/buddy/service/src/createBuddyCapabilityFactory.ts +++ b/apps/buddy/service/src/createBuddyCapabilityFactory.ts @@ -1,4 +1,5 @@ import type { ContextPanelSource } from '../../shared/context-panel/contextPanel' +import type { ApplicationDiagnosticReporter } from '../../shared/diagnostics/applicationDiagnostic' import type { BuddyFeatureId, BuddyPlatform } from '../../shared/platform' import type { RuntimeRpcPeerContract } from '../../shared/runtime/rpcPeer' import type { BuddyCapability, BuddyCapabilityContext, BuddyCapabilityFactory } from './agent/extensions/BuddyCapability' @@ -9,7 +10,8 @@ import type { McpConnectorService } from './connectors/mcp/McpConnectorService' import type { ImageGenerationGateway } from './images/ImageGenerationGateway' import type { ImageGenerationServiceOptions } from './images/ImageGenerationService' import type { ImageTransformService } from './images/ImageTransformService' -import type { PetActionServiceOptions } from './pet/PetActionService' +import type { PetActionService } from './pet/PetActionService' +import type { PluginAuthoringService } from './plugins/PluginAuthoringService' import type { WebCapabilityService } from './web/WebCapabilityService' import { createOutputPresentationCapability } from './artifacts/outputPresentationExtension' import { createAutomationCapability } from './automations/automationExtension' @@ -18,8 +20,8 @@ import { createMcpCapability } from './connectors/mcp/mcpExtension' import { createMcpResultWriter } from './connectors/mcp/McpResultStore' import { createImageGenerationCapability } from './images/imageGenerationExtension' import { ImageGenerationService } from './images/ImageGenerationService' +import { observeImageDiagnostics } from './images/ImageOperationLifecycle' import { createImageTransformCapability } from './images/imageTransformExtension' -import { PetActionService } from './pet/PetActionService' import { createPetCapability } from './pet/petExtension' import { createPluginAuthoringCapability } from './plugins/pluginAuthoringCapability' import { createSystemHost } from './system/createSystemHost' @@ -27,7 +29,10 @@ import { createSystemCapability } from './system/systemExtension' import { createWebCapability } from './web/webExtension' export interface BuddyCapabilityServices { + record?: ApplicationDiagnosticReporter + pluginCapabilities?: (context: BuddyCapabilityContext) => Promise pluginAuthoring: Pick + pluginBuilder: PluginAuthoringService artifactService: ImageGenerationServiceOptions['artifactService'] & Pick attachmentService: ImageGenerationServiceOptions['attachmentService'] automationService: CreateAutomationToolOptions['service'] @@ -36,23 +41,21 @@ export interface BuddyCapabilityServices { connectorService: Pick imageGenerationGateway: ImageGenerationGateway imageTransformService: Pick - onAutomationChanged: (automationId: string) => void webService: Pick } export function createBuddyCapabilityFactory( platform: BuddyPlatform, services: BuddyCapabilityServices, - pet: PetActionServiceOptions, + pet: PetActionService, ): BuddyCapabilityFactory { const platformFactories: Record (context: BuddyCapabilityContext) => BuddyCapability> = { nativePet() { - const service = new PetActionService(pet) - return context => createPetCapability({ getRunId: context.getRunId, service }) + return context => createPetCapability({ getRunId: context.getRunId, service: pet }) }, systemActions() { const host = createSystemHost(platform.id) - return () => createSystemCapability(host) + return () => createSystemCapability(host, services.record) }, } const supported = platform.features.map(id => platformFactories[id]()) @@ -63,6 +66,7 @@ export function createBuddyCapabilityFactory( const capabilities = [ createMcpCapability(mcp), createBrowserCapability({ + report: services.record, conversationId: context.conversationId, getGrants: () => context.grants, getExecutionGrants: context.getExecutionGrants, @@ -76,29 +80,47 @@ export function createBuddyCapabilityFactory( : undefined, }), createWebCapability({ service: services.webService, conversationId: context.conversationId }), - createImageGenerationCapability({ - getRunId: context.getRunId, - getExecutionGrants: context.getExecutionGrants, - service: new ImageGenerationService({ - artifactService: services.artifactService, - attachmentService: services.attachmentService, - conversationId: context.conversationId, - cwd: context.cwd, - grants: context.grants, - imageGenerationGateway: services.imageGenerationGateway, - }), - }), + createImageCapability(context, services), createImageTransformCapability({ ...context, service: services.imageTransformService }), createOutputPresentationCapability({ ...context, artifactService: services.artifactService }), ...supported.map(create => create(context)), ] - if (context.sessionMode === 'interactive') { - capabilities.push(createPluginAuthoringCapability(context, services.pluginAuthoring)) - capabilities.push(createAutomationCapability({ - onChanged: services.onAutomationChanged, - service: services.automationService, - })) + try { + if (context.sessionMode === 'interactive') { + capabilities.push(...await services.pluginCapabilities?.(context) ?? []) + capabilities.push(createPluginAuthoringCapability(context, services.pluginAuthoring, services.pluginBuilder)) + capabilities.push(createAutomationCapability({ + service: services.automationService, + })) + } + context.signal.throwIfAborted() + return capabilities } - return capabilities + catch (error) { + const results = await Promise.allSettled(capabilities.map(async capability => capability.dispose?.())) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) + throw new AggregateError([error, ...failures.map(result => result.reason)], 'CAPABILITY_INITIALIZATION_FAILED') + throw error + } + } +} + +function createImageCapability(context: BuddyCapabilityContext, services: BuddyCapabilityServices): BuddyCapability { + const service = new ImageGenerationService({ + artifactService: services.artifactService, + attachmentService: services.attachmentService, + conversationId: context.conversationId, + cwd: context.cwd, + get grants() { return context.grants }, + imageGenerationGateway: services.imageGenerationGateway, + }) + const diagnostics = services.record ? observeImageDiagnostics(service, services.record) : null + return { + ...createImageGenerationCapability({ getRunId: context.getRunId, getExecutionGrants: context.getExecutionGrants, service }), + async dispose() { + await service.dispose() + diagnostics?.dispose() + }, } } diff --git a/apps/buddy/service/src/directories/DirectoryGrantService.ts b/apps/buddy/service/src/directories/DirectoryGrantService.ts index c53723da..100563b6 100644 --- a/apps/buddy/service/src/directories/DirectoryGrantService.ts +++ b/apps/buddy/service/src/directories/DirectoryGrantService.ts @@ -7,6 +7,8 @@ import type { ConversationRepository } from '../storage/conversationRepository' import { randomUUID } from 'node:crypto' import { mkdir, realpath, stat } from 'node:fs/promises' import { resolve } from 'node:path' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export interface DirectoryGrantMutation { changed: boolean @@ -22,25 +24,50 @@ export interface DirectoryGrantServiceOptions { conversationGrants: ConversationDirectoryGrantRepository conversations: Pick spaces: { + isGrantCurrent: (spaceId: string, grantId: string) => boolean grantAdditionalDirectory: (input: { root: string spaceId: string }) => Promise } + onListenerError?: (error: unknown) => void +} + +export interface ConversationDirectoryGrantCommit { + readonly revision: number + readonly conversationId: string + readonly grantId: string + readonly revokedGrantIds: readonly string[] } export class DirectoryGrantService { readonly #conversationGrants: ConversationDirectoryGrantRepository readonly #conversations: DirectoryGrantServiceOptions['conversations'] readonly #spaces: DirectoryGrantServiceOptions['spaces'] + readonly #changes: Emitter + readonly onDidCommit + #revision = 0 + #disposed = false + #quiescing = false + readonly #pending = new Set>() constructor(options: DirectoryGrantServiceOptions) { this.#conversationGrants = options.conversationGrants this.#conversations = options.conversations this.#spaces = options.spaces + this.#changes = new Emitter(options.onListenerError ?? (() => console.error('DIRECTORY_GRANT_OBSERVER_FAILED'))) + this.onDidCommit = this.#changes.event + } + + grant(input: { owner: GrantOwner, root: string }): Promise { + if (this.#disposed || this.#quiescing) + return Promise.reject(new DirectoryGrantError('DIRECTORY_GRANT_OWNER_INVALID')) + const pending = this.#grant(copyEventSnapshot(input)).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + return pending } - async grant(input: { owner: GrantOwner, root: string }): Promise { + async #grant(input: { owner: GrantOwner, root: string }): Promise { if (input.owner.kind === 'space') { return this.#spaces.grantAdditionalDirectory({ root: input.root, @@ -52,6 +79,8 @@ export class DirectoryGrantService { if (!conversation || conversation.deletedAt !== null || conversation.spaceId !== null) throw new DirectoryGrantError('DIRECTORY_GRANT_OWNER_INVALID') const root = await resolveDirectory(input.root) + if (this.#disposed) + throw new DirectoryGrantError('DIRECTORY_GRANT_OWNER_INVALID') const result = this.#conversationGrants.grant({ canonicalRoot: root, conversationId: conversation.id, @@ -59,7 +88,34 @@ export class DirectoryGrantService { id: randomUUID(), root, }) - return toMutation(result) + if (result.changed) + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, conversationId: conversation.id, grantId: result.grant.id, revokedGrantIds: result.coveredGrantIds })) + return copyEventSnapshot(toMutation(result)) + } + + assertCurrent(owner: GrantOwner, grantId: string): void { + if (owner.kind === 'space') { + if (this.#spaces.isGrantCurrent(owner.id, grantId)) + return + } + else { + const conversation = this.#conversations.findById(owner.id) + if (conversation && conversation.deletedAt === null && conversation.spaceId === null && this.#conversationGrants.listActive(owner.id).some(grant => grant.id === grantId)) + return + } + throw new DirectoryGrantError('DIRECTORY_GRANT_OWNER_INVALID') + } + + async quiesce(): Promise { + this.#quiescing = true + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + async dispose(): Promise { + await this.quiesce() + this.#disposed = true + this.#changes.dispose() } } diff --git a/apps/buddy/service/src/directories/SessionDirectoryGrants.ts b/apps/buddy/service/src/directories/SessionDirectoryGrants.ts new file mode 100644 index 00000000..c3445719 --- /dev/null +++ b/apps/buddy/service/src/directories/SessionDirectoryGrants.ts @@ -0,0 +1,49 @@ +import type { DirectoryGrantMutation } from './DirectoryGrantService' +import type { DirectoryGrant } from './resolveGrantedPath' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' + +export interface SessionDirectoryGrantChange { + readonly revision: number + readonly kind: 'applied' | 'cleared' + readonly count: number + readonly removedCount: number +} + +export class SessionDirectoryGrants { + readonly #changes = new Emitter(() => console.error('SESSION_GRANT_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #snapshot: readonly Readonly[] + #revision = 0 + #disposed = false + + constructor(grants: readonly DirectoryGrant[]) { + this.#snapshot = copyEventSnapshot(grants) + } + + get snapshot(): readonly Readonly[] { return this.#snapshot } + + apply(mutation: DirectoryGrantMutation): void { + if (this.#disposed) + throw new Error('SESSION_GRANTS_STOPPED') + const covered = new Set(mutation.coveredGrantIds) + const grants = this.#snapshot.filter(grant => !covered.has(grant.grantId)) + if (grants.length === this.#snapshot.length && grants.some(grant => grant.grantId === mutation.grant.id)) + return + const removedCount = this.#snapshot.length - grants.length + if (!grants.some(grant => grant.grantId === mutation.grant.id)) + grants.push({ canonicalRoot: mutation.grant.canonicalRoot, grantId: mutation.grant.id, kind: 'granted', root: mutation.grant.root }) + this.#snapshot = copyEventSnapshot(grants) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, kind: 'applied', count: grants.length, removedCount })) + } + + dispose(): void { + if (this.#disposed) + return + this.#disposed = true + const removedCount = this.#snapshot.length + this.#snapshot = Object.freeze([]) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, kind: 'cleared', count: 0, removedCount })) + this.#changes.dispose() + } +} diff --git a/apps/buddy/service/src/directories/__tests__/DirectoryGrantService.spec.ts b/apps/buddy/service/src/directories/__tests__/DirectoryGrantService.spec.ts index ab754050..68ec20d3 100644 --- a/apps/buddy/service/src/directories/__tests__/DirectoryGrantService.spec.ts +++ b/apps/buddy/service/src/directories/__tests__/DirectoryGrantService.spec.ts @@ -21,6 +21,33 @@ afterEach(async () => { }) describe('directoryGrantService', () => { + it('keeps persistence distinct from active-session application after a revocation', async () => { + const fixture = await createFixture() + const events: unknown[] = [] + fixture.service.onDidCommit((event) => { + events.push(event) + fixture.repository.revokeAll(event.conversationId, timestamp) + }) + const owner = { id: 'conversation-1', kind: 'conversation' as const } + const result = await fixture.service.grant({ owner, root: fixture.child }) + expect(result.changed).toBe(true) + expect(events).toMatchObject([{ conversationId: owner.id, grantId: result.grant.id, revision: 1 }]) + expect(() => fixture.service.assertCurrent(owner, result.grant.id)).toThrow() + expect(fixture.repository.listActive(owner.id)).toEqual([]) + expect(JSON.stringify(events)).not.toContain(fixture.child) + }) + + it('checks the owner in the transaction after asynchronous directory validation', async () => { + const fixture = await createFixture() + const events: unknown[] = [] + fixture.service.onDidCommit(event => events.push(event)) + const pending = fixture.service.grant({ owner: { kind: 'conversation', id: 'conversation-1' }, root: fixture.child }) + fixture.database.prepare('UPDATE conversations SET deleted_at = ? WHERE id = ?').run(timestamp, 'conversation-1') + await expect(pending).rejects.toThrow() + expect(fixture.repository.listActive('conversation-1')).toEqual([]) + expect(events).toEqual([]) + }) + it('persists a conversation grant and reuses a covering grant', async () => { const fixture = await createFixture() const granted = await fixture.service.grant({ @@ -118,10 +145,11 @@ async function createFixture(options: { spaceId?: string } = {}) { conversationGrants: repository, conversations, spaces: { + isGrantCurrent: () => false, grantAdditionalDirectory: async () => { throw new Error('Space grant was not expected') }, }, }) - return { child, repository, root, service } + return { child, database, repository, root, service } } diff --git a/apps/buddy/service/src/events/RunEventLog.ts b/apps/buddy/service/src/events/RunEventLog.ts index cc7720c9..7f524f11 100644 --- a/apps/buddy/service/src/events/RunEventLog.ts +++ b/apps/buddy/service/src/events/RunEventLog.ts @@ -1,3 +1,4 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { AppendBuddyRunEventInput, BuddyRunEvent, @@ -7,6 +8,8 @@ import type { RunEventLogPort } from './RunEventPorts' import type { RunEventProjector } from './RunEventProjector' import type { RunEventQueries } from './RunEventQueries' import type { RunEventStore } from './RunEventStore' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { buddyRunEventSchema, buddyRunIdSchema, @@ -19,8 +22,7 @@ import { } from './RunEventFailure' export interface RunEventLogCallbacks { - onEvent?: (event: BuddyRunEvent) => void - onEventDeliveryError?: (error: Error, event: BuddyRunEvent) => void + onObserverError?: (error: unknown) => void onFatalFailure?: (error: RunEventLogFatalError) => void } @@ -59,8 +61,8 @@ export class RunEventLogClosedError extends Error { export class RunEventLog implements RunEventLogPort { readonly #nextSequences = new Map() - readonly #onEvent?: (event: BuddyRunEvent) => void - readonly #onEventDeliveryError?: (error: Error, event: BuddyRunEvent) => void + readonly #committed: Emitter> + readonly onDidCommit: RunEventLogPort['onDidCommit'] readonly #onFatalFailure?: (error: RunEventLogFatalError) => void readonly #projector: RunEventProjectorPort readonly #queries: RunEventQueryPort @@ -71,14 +73,18 @@ export class RunEventLog implements RunEventLogPort { #fatalFailure: RunEventLogFatalError | null = null constructor(options: RunEventLogOptions) { - this.#onEvent = options.onEvent - this.#onEventDeliveryError = options.onEventDeliveryError + this.#committed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidCommit = this.#committed.event this.#onFatalFailure = options.onFatalFailure this.#projector = options.projector this.#queries = options.queries this.#store = options.store } + get state(): RunEventLogPort['state'] { + return this.#closed ? 'closed' : this.#fatalFailure ? 'failed' : 'open' + } + append(input: AppendBuddyRunEventInput): Promise { return this.appendBatch([input]).then(events => events[0]!) } @@ -101,19 +107,20 @@ export class RunEventLog implements RunEventLogPort { const existing = await this.#readAndRepair(runId) nextSequence = nextEventSequence(existing) } - const events = inputs.map((input, index) => buddyRunEventSchema.parse({ + const events = inputs.map((input, index) => buddyRunEventSchema.parse(JSON.parse(JSON.stringify({ runId, sequence: nextSequence + index, type: input.type, payload: input.payload ?? null, createdAt: input.createdAt ?? new Date().toISOString(), - })) + })))) + const committed = events.map(event => copyEventSnapshot(event)) this.#projector.validateNewFacts(events) await this.#runStoreOperation(() => this.#store.append(events)) this.#nextSequences.set(runId, nextSequence + events.length) await this.#projectCommitted(events) - for (const event of events) - this.#deliver(event) + for (const event of committed) + this.#committed.fire(event) return events }) } @@ -174,7 +181,7 @@ export class RunEventLog implements RunEventLogPort { if (this.#closePromise) return this.#closePromise this.#closed = true - this.#closePromise = Promise.all(this.#tails.values()).then(() => undefined) + this.#closePromise = Promise.all(this.#tails.values()).then(() => this.#committed.dispose()) return this.#closePromise } @@ -213,18 +220,6 @@ export class RunEventLog implements RunEventLogPort { return removed.length } - #deliver(event: BuddyRunEvent): void { - try { - this.#onEvent?.(event) - } - catch (error) { - try { - this.#onEventDeliveryError?.(toEventDeliveryError(error), event) - } - catch {} - } - } - async #projectCommitted(events: readonly BuddyRunEvent[]): Promise { try { this.#projector.project(events) @@ -317,9 +312,3 @@ export class RunEventLog implements RunEventLogPort { function nextEventSequence(events: readonly BuddyRunEvent[]): number { return (events.at(-1)?.sequence ?? 0) + 1 } - -function toEventDeliveryError(error: unknown): Error { - return error instanceof Error - ? error - : new Error('Lexora Buddy run event notification failed') -} diff --git a/apps/buddy/service/src/events/RunEventPorts.ts b/apps/buddy/service/src/events/RunEventPorts.ts index 65daaee4..20e4e7cd 100644 --- a/apps/buddy/service/src/events/RunEventPorts.ts +++ b/apps/buddy/service/src/events/RunEventPorts.ts @@ -1,3 +1,5 @@ +import type { Event } from '../../../shared/events/Emitter' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { AppendBuddyRunEventInput, BuddyRunEvent, @@ -30,5 +32,10 @@ export interface RunEventMaintenance { replayAll: () => Promise } +export interface RunEventObservation { + readonly onDidCommit: Event> + readonly state: 'open' | 'failed' | 'closed' +} + export interface RunEventLogPort - extends RunEventMaintenance, RunEventReader, RunEventWriter {} + extends RunEventMaintenance, RunEventReader, RunEventWriter, RunEventObservation {} diff --git a/apps/buddy/service/src/events/__tests__/RunEventLog.spec.ts b/apps/buddy/service/src/events/__tests__/RunEventLog.spec.ts index 25601b5f..4e974a09 100644 --- a/apps/buddy/service/src/events/__tests__/RunEventLog.spec.ts +++ b/apps/buddy/service/src/events/__tests__/RunEventLog.spec.ts @@ -24,6 +24,49 @@ afterEach(async () => { }) describe('runEventLog', () => { + it('does not publish a committed record when its required projection cannot recover', async () => { + const fixture = await createFixture() + const delivered: number[] = [] + fixture.log.onDidCommit(event => delivered.push(event.sequence)) + fixture.database.exec(`CREATE TRIGGER reject_new_message BEFORE INSERT ON messages + BEGIN SELECT RAISE(ABORT, 'projection unavailable'); END;`) + await expect(fixture.log.append({ + runId: 'run-1', + type: 'message.completed', + payload: { messageId: 'answer-1', role: 'assistant', content: { text: 'answer' }, stopReason: 'completed' }, + })).rejects.toMatchObject({ code: 'EVENT_PROJECTION_FAILED', commitState: 'committed' }) + expect(fixture.log.state).toBe('failed') + expect(delivered).toEqual([]) + expect((await fixture.log.read('run-1')).map(event => event.type)).toEqual(['message.completed']) + }) + + it('publishes immutable committed facts after projection and retains observers after failure', async () => { + const fixture = await createFixture() + const delivered: number[] = [] + fixture.log.onDidCommit(() => { + throw new Error('Observer unavailable') + }) + fixture.log.onDidCommit(async () => { + throw new Error('Async observer unavailable') + }) + fixture.log.onDidCommit((event) => { + expect(fixture.database.prepare('SELECT sequence FROM run_events WHERE run_id = ? AND sequence = ?').get(event.runId, event.sequence)).toBeDefined() + expect(Object.isFrozen(event)).toBe(true) + expect(Object.isFrozen(event.payload)).toBe(true) + delivered.push(event.sequence) + }) + await fixture.log.appendBatch([ + { runId: 'run-1', type: 'audit.first', payload: { nested: { count: 1 } } }, + { runId: 'run-1', type: 'audit.second', payload: {} }, + ]) + await fixture.log.replay('run-1') + const accepted = fixture.log.append({ runId: 'run-1', type: 'audit.third', payload: {} }) + await fixture.log.close() + await accepted + expect(delivered).toEqual([1, 2, 3]) + expect(fixture.log.state).toBe('closed') + }) + it('persists desktop operations after completion and replays them without creating model messages', async () => { const fixture = await createFixture() await fixture.log.append({ runId: 'run-1', type: 'run.completed', payload: {} }) diff --git a/apps/buddy/service/src/events/createRunEventLog.ts b/apps/buddy/service/src/events/createRunEventLog.ts index 33a6a734..ce05604a 100644 --- a/apps/buddy/service/src/events/createRunEventLog.ts +++ b/apps/buddy/service/src/events/createRunEventLog.ts @@ -13,8 +13,7 @@ export interface CreateRunEventLogOptions extends RunEventLogCallbacks { export function createRunEventLog(options: CreateRunEventLogOptions): RunEventLog { const queries = new RunEventQueries(options.database) return new RunEventLog({ - onEvent: options.onEvent, - onEventDeliveryError: options.onEventDeliveryError, + onObserverError: options.onObserverError, onFatalFailure: options.onFatalFailure, projector: new RunEventProjector(options.database), queries, diff --git a/apps/buddy/service/src/events/observeRunDiagnostics.ts b/apps/buddy/service/src/events/observeRunDiagnostics.ts new file mode 100644 index 00000000..8c15136f --- /dev/null +++ b/apps/buddy/service/src/events/observeRunDiagnostics.ts @@ -0,0 +1,38 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { RunRepository } from '../storage/runRepository' +import type { RunEventObservation } from './RunEventPorts' +import { diagnosticIdentitySchema, safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' + +export function observeRunDiagnostics( + eventLog: Pick, + runs: Pick, + report: ApplicationDiagnosticReporter, +) { + const record = safeDiagnosticReporter(report) + return eventLog.onDidCommit((event) => { + if (!['run.started', 'run.completed', 'run.failed', 'run.cancelled', 'tool.started', 'tool.completed', 'approval.requested', 'approval.resolved', 'approval.reused'].includes(event.type)) + return + const run = runs.findById(event.runId) + const payload = event.payload && typeof event.payload === 'object' ? event.payload as Record : null + const toolCallId = diagnosticIdentitySchema.safeParse(payload?.toolCallId) + const approvalId = diagnosticIdentitySchema.safeParse(event.type === 'approval.reused' ? payload?.sourceApprovalId : event.type.startsWith('approval.') ? payload?.id : undefined) + const type = event.type === 'tool.completed' && payload?.isError === true + ? 'tool.failed' + : event.type === 'approval.resolved' && ['approved', 'denied', 'cancelled'].includes(String(payload?.status)) + ? `approval.resolved.${payload?.status}` + : event.type + record({ + event: type, + level: type === 'run.failed' || type === 'tool.failed' ? 'error' : 'info', + runId: event.runId, + revision: event.sequence, + occurredAt: event.createdAt, + conversationId: run?.conversationId, + branchId: run?.branchId, + ...(toolCallId.success ? { toolCallId: toolCallId.data } : {}), + ...(approvalId.success ? { operationId: approvalId.data } : {}), + ...(run?.errorCode ? { errorCode: run.errorCode } : {}), + ...(run?.completedAt ? { durationMs: Math.max(0, Date.parse(run.completedAt) - Date.parse(run.startedAt)) } : {}), + }) + }) +} diff --git a/apps/buddy/service/src/images/ImageGenerationService.ts b/apps/buddy/service/src/images/ImageGenerationService.ts index 1d8a8f96..a71bea67 100644 --- a/apps/buddy/service/src/images/ImageGenerationService.ts +++ b/apps/buddy/service/src/images/ImageGenerationService.ts @@ -1,7 +1,9 @@ import type { Api, ImageContent, Model } from '@earendil-works/pi-ai' import type { DirectoryGrant } from '../directories/resolveGrantedPath' import type { ImageGenerationGateway } from './ImageGenerationGateway' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { ImageGenerationError } from './ImageGenerationGateway' +import { ImageOperationLifecycle } from './ImageOperationLifecycle' export interface GenerateConversationImageInput { outputPath: string @@ -36,42 +38,61 @@ export interface ImageGenerationServiceOptions { export class ImageGenerationService { readonly #options: ImageGenerationServiceOptions + readonly #operations = new ImageOperationLifecycle() + readonly onDidChange = this.#operations.onDidChange constructor(options: ImageGenerationServiceOptions) { - this.#options = options + this.#options = { + artifactService: options.artifactService, + attachmentService: options.attachmentService, + conversationId: options.conversationId, + cwd: options.cwd, + get grants() { return options.grants }, + imageGenerationGateway: options.imageGenerationGateway, + } } supports(model: Model): boolean { return this.#options.imageGenerationGateway.supports(model) } - async generate(input: GenerateConversationImageInput, model: Model, signal: AbortSignal, grants = this.#options.grants) { - signal.throwIfAborted() - if (!this.supports(model)) - throw new ImageGenerationError('IMAGE_GENERATION_UNSUPPORTED') - const references = input.reference - ? await this.#materializeReferences(input.reference) - : { artifactIds: [], images: [] } - signal.throwIfAborted() - const generated = await this.#options.imageGenerationGateway.generate({ - inputImages: references.images, - model, - prompt: input.prompt.trim(), - signal, - }) - signal.throwIfAborted() - const artifacts = await this.#options.artifactService.registerGeneratedImages({ - conversationId: this.#options.conversationId, - cwd: this.#options.cwd, - grants, - images: generated.images, - outputPath: input.outputPath.trim(), - sourceArtifactId: references.artifactIds.at(-1) ?? null, + generate(input: GenerateConversationImageInput, model: Model, signal: AbortSignal, grants = this.#options.grants) { + const request = copyEventSnapshot(input) + const directories = copyEventSnapshot(grants) + return this.#operations.run({ conversationId: this.#options.conversationId, kind: 'generation', signal }, async (progress) => { + signal.throwIfAborted() + if (!this.supports(model)) + throw new ImageGenerationError('IMAGE_GENERATION_UNSUPPORTED') + const references = request.reference + ? await this.#materializeReferences(request.reference) + : { artifactIds: [], images: [] } + signal.throwIfAborted() + progress('processing') + const generated = await this.#options.imageGenerationGateway.generate({ + inputImages: references.images, + model, + prompt: request.prompt.trim(), + signal, + }) + progress('result-received') + signal.throwIfAborted() + progress('publishing') + signal?.throwIfAborted() + const artifacts = await this.#options.artifactService.registerGeneratedImages({ + conversationId: this.#options.conversationId, + cwd: this.#options.cwd, + grants: directories, + images: generated.images, + outputPath: request.outputPath.trim(), + sourceArtifactId: references.artifactIds.at(-1) ?? null, + }) + const artifactIds = artifacts.map(artifact => artifact.id) + return { value: { artifactIds, responseId: generated.responseId }, artifactIds } }) - return { - artifactIds: artifacts.map(artifact => artifact.id), - responseId: generated.responseId, - } + } + + dispose(): Promise { + return this.#operations.dispose() } async #materializeReferences( diff --git a/apps/buddy/service/src/images/ImageOperationLifecycle.ts b/apps/buddy/service/src/images/ImageOperationLifecycle.ts new file mode 100644 index 00000000..2c354d71 --- /dev/null +++ b/apps/buddy/service/src/images/ImageOperationLifecycle.ts @@ -0,0 +1,74 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ArtifactBatchReceipt } from '../artifacts/ArtifactService' +import { randomUUID } from 'node:crypto' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { ArtifactPublicationError } from '../artifacts/ArtifactService' + +export interface ImageOperationEvent { + readonly revision: number + readonly operationId: string + readonly conversationId: string + readonly kind: 'generation' | 'transform' + readonly phase: 'preparing' | 'processing' | 'result-received' | 'publishing' | 'settled' + readonly outcome?: 'completed' | 'cancelled' | 'partial' | 'failed' + readonly cancellationRequested: boolean + readonly artifactIds: readonly string[] + readonly publication?: ArtifactBatchReceipt + readonly errorCode?: 'IMAGE_OPERATION_FAILED' +} + +export class ImageOperationLifecycle { + readonly #changes = new Emitter(() => console.error('IMAGE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Set>() + #revision = 0 + #disposed = false + + run(input: { conversationId: string, kind: ImageOperationEvent['kind'], signal?: AbortSignal }, operation: (progress: (phase: Exclude) => void) => Promise<{ value: T, artifactIds: readonly string[] }>): Promise { + if (this.#disposed) + return Promise.reject(new Error('IMAGE_SERVICE_STOPPED')) + const operationId = randomUUID() + const publish = (details: Pick & { artifactIds?: readonly string[] }) => { + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, operationId, conversationId: input.conversationId, kind: input.kind, cancellationRequested: input.signal?.aborted ?? false, artifactIds: [], ...details })) + } + const pending = Promise.resolve().then(async () => { + publish({ phase: 'preparing' }) + try { + const result = await operation(phase => publish({ phase })) + publish({ phase: 'settled', outcome: 'completed', artifactIds: result.artifactIds }) + return result.value + } + catch (error) { + const publication = error instanceof ArtifactPublicationError ? error.receipt : undefined + const outcome = publication ? publication.outcome === 'partial' ? 'partial' : 'failed' : input.signal?.aborted ? 'cancelled' : 'failed' + publish({ phase: 'settled', outcome, publication, artifactIds: publication?.artifactIds ?? [], errorCode: 'IMAGE_OPERATION_FAILED' }) + throw error + } + }).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + return pending + } + + async dispose(): Promise { + this.#disposed = true + await Promise.allSettled([...this.#pending]) + this.#changes.dispose() + } +} + +export function observeImageDiagnostics(source: Pick, report: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report) + return source.onDidChange((event) => { + record({ + event: `image.${event.kind}.${event.phase.replaceAll('-', '_')}${event.outcome ? `.${event.outcome}` : ''}`, + level: event.outcome === 'failed' || event.outcome === 'partial' ? 'warn' : 'info', + conversationId: event.conversationId, + operationId: event.operationId, + revision: event.revision, + count: event.artifactIds.length, + errorCode: event.errorCode, + }) + }) +} diff --git a/apps/buddy/service/src/images/ImageTransformService.ts b/apps/buddy/service/src/images/ImageTransformService.ts index 8ebedc5a..885e7fad 100644 --- a/apps/buddy/service/src/images/ImageTransformService.ts +++ b/apps/buddy/service/src/images/ImageTransformService.ts @@ -2,6 +2,8 @@ import type { ArtifactService } from '../artifacts/ArtifactService' import type { DirectoryGrant } from '../directories/resolveGrantedPath' import type { ArtifactRecord } from '../storage/artifactRepository' import type { ChromaOptions } from './runChromaTransform' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { ImageOperationLifecycle } from './ImageOperationLifecycle' import { ImageTransformError } from './ImageTransformError' import { runChromaTransform } from './runChromaTransform' @@ -14,6 +16,8 @@ export interface RemoveChromaInput extends ChromaOptions { } export class ImageTransformService { + readonly #operations = new ImageOperationLifecycle() + readonly onDidChange = this.#operations.onDidChange readonly #artifacts: Pick constructor(options: { @@ -22,31 +26,42 @@ export class ImageTransformService { this.#artifacts = options.artifacts } - async removeChroma(input: RemoveChromaInput, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - if (!input.outputPath.trim()) - throw new ImageTransformError('VALIDATION_FAILED') - const source = await this.#artifacts.materializeConversationArtifact(input.conversationId, input.sourceArtifactId) - signal?.throwIfAborted() - if (source.resource.mimeType !== 'image/png') - throw new ImageTransformError('IMAGE_TRANSFORM_UNSUPPORTED_FORMAT') - const bytes = await runChromaTransform(source.bytes, { - color: input.color, - despill: input.despill, - softness: input.softness, - tolerance: input.tolerance, - }, signal) - signal?.throwIfAborted() - const [artifact] = await this.#artifacts.registerGeneratedImages({ - conversationId: input.conversationId, - cwd: input.cwd, - grants: input.grants, - images: [{ bytes, mimeType: 'image/png' }], - outputPath: input.outputPath, - sourceArtifactId: input.sourceArtifactId, + removeChroma(input: RemoveChromaInput, signal?: AbortSignal): Promise { + const request = copyEventSnapshot({ conversationId: input.conversationId, cwd: input.cwd, grants: input.grants, outputPath: input.outputPath, sourceArtifactId: input.sourceArtifactId, color: input.color, despill: input.despill, softness: input.softness, tolerance: input.tolerance }) + return this.#operations.run({ conversationId: request.conversationId, kind: 'transform', signal }, async (progress) => { + signal?.throwIfAborted() + if (!request.outputPath.trim()) + throw new ImageTransformError('VALIDATION_FAILED') + const source = await this.#artifacts.materializeConversationArtifact(request.conversationId, request.sourceArtifactId) + signal?.throwIfAborted() + if (source.resource.mimeType !== 'image/png') + throw new ImageTransformError('IMAGE_TRANSFORM_UNSUPPORTED_FORMAT') + progress('processing') + const bytes = await runChromaTransform(source.bytes, { + color: request.color, + despill: request.despill, + softness: request.softness, + tolerance: request.tolerance, + }, signal) + progress('result-received') + signal?.throwIfAborted() + progress('publishing') + signal?.throwIfAborted() + const [artifact] = await this.#artifacts.registerGeneratedImages({ + conversationId: request.conversationId, + cwd: request.cwd, + grants: request.grants, + images: [{ bytes, mimeType: 'image/png' }], + outputPath: request.outputPath, + sourceArtifactId: request.sourceArtifactId, + }) + if (!artifact) + throw new ImageTransformError('IMAGE_TRANSFORM_FAILED') + return { value: artifact, artifactIds: [artifact.id] } }) - if (!artifact) - throw new ImageTransformError('IMAGE_TRANSFORM_FAILED') - return artifact + } + + dispose(): Promise { + return this.#operations.dispose() } } diff --git a/apps/buddy/service/src/images/__tests__/ImageGenerationService.spec.ts b/apps/buddy/service/src/images/__tests__/ImageGenerationService.spec.ts index 1f243e70..01110ad3 100644 --- a/apps/buddy/service/src/images/__tests__/ImageGenerationService.spec.ts +++ b/apps/buddy/service/src/images/__tests__/ImageGenerationService.spec.ts @@ -1,12 +1,65 @@ import type { Model } from '@earendil-works/pi-ai' import type { ImageGenerationServiceOptions } from '../ImageGenerationService' +import type { ImageOperationEvent } from '../ImageOperationLifecycle' import { describe, expect, it } from 'vitest' +import { ArtifactPublicationError } from '../../artifacts/ArtifactService' import { ImageGenerationService } from '../ImageGenerationService' const image = { type: 'image' as const, mimeType: 'image/png', data: 'fixture' } const model = { provider: 'fixture', id: 'fixture' } as Model<'openai-responses'> describe('conversation image generation', () => { + it('settles accepted publication with actual artifacts after cancellation and drains disposal', async () => { + const controller = new AbortController() + let release!: () => void + let started!: () => void + const publicationStarted = new Promise((resolve) => { + started = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const source = { materializeConversationImages: async () => ({ images: [], records: [] }) } + const service = new ImageGenerationService({ conversationId: 'conversation-1', cwd: '/workspace', grants: [], attachmentService: source, artifactService: { ...source, async registerGeneratedImages() { + started() + + await gate + + return [{ id: 'generated-1' }] + } }, imageGenerationGateway: { supports: () => true, generate: async () => ({ images: [{ bytes: Uint8Array.of(1), mimeType: 'image/png' }], responseId: 'private-response' }) } }) + const events: ImageOperationEvent[] = [] + service.onDidChange(event => events.push(event)) + const pending = service.generate({ outputPath: 'private.png', prompt: 'private prompt' }, model, controller.signal) + await publicationStarted + controller.abort() + let disposed = false + const stopping = service.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + release() + await expect(pending).resolves.toEqual({ artifactIds: ['generated-1'], responseId: 'private-response' }) + await stopping + expect(events.map(event => event.phase)).toEqual(['preparing', 'processing', 'result-received', 'publishing', 'settled']) + expect(events.at(-1)).toMatchObject({ outcome: 'completed', cancellationRequested: true, artifactIds: ['generated-1'] }) + expect(Object.isFrozen(events.at(-1)!.artifactIds)).toBe(true) + expect(JSON.stringify(events)).not.toContain('private') + await expect(service.generate({ outputPath: 'later.png', prompt: 'later' }, model, controller.signal)).rejects.toThrow('IMAGE_SERVICE_STOPPED') + }) + + it('preserves publication partial effects in image settlement', async () => { + const source = { materializeConversationImages: async () => ({ images: [], records: [] }) } + const service = new ImageGenerationService({ conversationId: 'conversation-1', cwd: '/workspace', grants: [], attachmentService: source, artifactService: { ...source, async registerGeneratedImages() { + throw new ArtifactPublicationError(new Error('storage failed'), { operationId: 'publication-1', conversationId: 'conversation-1', cause: 'generated', requested: 2, written: 2, unconfirmedWrites: 0, artifactIds: ['generated-1'], stage: 'catalogue', outcome: 'partial' }) + } }, imageGenerationGateway: { supports: () => true, generate: async () => ({ images: [{ bytes: Uint8Array.of(1), mimeType: 'image/png' }], responseId: 'private-response' }) } }) + const events: ImageOperationEvent[] = [] + service.onDidChange(event => events.push(event)) + await expect(service.generate({ outputPath: 'private.png', prompt: 'private prompt' }, model, new AbortController().signal)).rejects.toMatchObject({ receipt: { outcome: 'partial' } }) + expect(events.at(-1)).toMatchObject({ phase: 'settled', outcome: 'partial', artifactIds: ['generated-1'], publication: { written: 2, operationId: 'publication-1' } }) + await service.dispose() + }) + it('preserves reference order and uses the last artifact reference for lineage', async () => { const references: string[] = [] let saved: Parameters[0] | undefined @@ -41,7 +94,7 @@ describe('conversation image generation', () => { expect(saved).toMatchObject({ sourceArtifactId: 'artifact-2', outputPath: 'result.png', conversationId: 'conversation-1' }) }) - it('does not persist generated output when cancellation arrives during the provider request', async () => { + it.each(['provider', 'publishing'])('does not persist generated output when cancellation precedes file publication at %s', async (phase) => { const controller = new AbortController() let saved = false const source = { materializeConversationImages: async () => ({ images: [], records: [] }) } @@ -60,11 +113,16 @@ describe('conversation image generation', () => { imageGenerationGateway: { supports: () => true, async generate() { - controller.abort(new Error('cancelled')) + if (phase === 'provider') + controller.abort(new Error('cancelled')) return { images: [{ bytes: new Uint8Array([1]), mimeType: 'image/png' }], responseId: 'response-1' } }, }, }) + service.onDidChange((event) => { + if (phase === 'publishing' && event.phase === 'publishing') + controller.abort(new Error('cancelled')) + }) await expect(service.generate({ outputPath: 'result.png', prompt: 'generate' }, model, controller.signal)).rejects.toThrow('cancelled') expect(saved).toBe(false) }) diff --git a/apps/buddy/service/src/index.ts b/apps/buddy/service/src/index.ts index b2ea53f6..9eca9c9e 100644 --- a/apps/buddy/service/src/index.ts +++ b/apps/buddy/service/src/index.ts @@ -5,7 +5,10 @@ import { z } from 'zod' import { establishWindowsRuntimeGuard } from '../../platform/windows/runtimeGuard' import { APPLICATION_DIAGNOSTIC_METHOD, readDiagnosticErrorCode } from '../../shared/diagnostics/applicationDiagnostic' import { ServiceHost } from '../../shared/lifecycle/ServiceHost' +import { SERVICE_LIFECYCLE_METHOD } from '../../shared/lifecycle/serviceLifecycle' +import { ServiceLifecycleSource } from '../../shared/lifecycle/ServiceLifecycleSource' import { ApplicationEvents } from '../../shared/observability/ApplicationEvents' +import { observeLifecycleDiagnostics } from '../../shared/observability/lifecycleDiagnostics' import { OPERATING_SYSTEM } from '../../shared/platform/identifiers' import { toPublicRunEvent } from '../../shared/runs/publicRunEvent' import { runNotifications } from '../../shared/runs/runApi' @@ -49,7 +52,10 @@ async function runBuddyService(): Promise { const events = new ApplicationEvents() events.subscribe(event => serviceServer?.notify(APPLICATION_DIAGNOSTIC_METHOD, event)) const record = events.publish - const host = new ServiceHost(events) + const lifecycle = new ServiceLifecycleSource(() => record({ event: 'observer.failed', component: 'runtime.lifecycle', level: 'warn' })) + const host = new ServiceHost(lifecycle) + const stopLifecycleDiagnostics = observeLifecycleDiagnostics(host.lifecycle, events) + const lifecycleDelivery = host.lifecycle.onDidChange(change => serviceServer?.notify(SERVICE_LIFECYCLE_METHOD, change)) let serviceFailureNotified = false let isDatabaseClosed = false const closeDatabase = () => { @@ -83,6 +89,8 @@ async function runBuddyService(): Promise { failed = true } record({ event: failed ? 'service.stop_failed' : 'service.stopped', level: failed ? 'error' : 'info', component: 'runtime.service' }) + lifecycleDelivery.dispose() + stopLifecycleDiagnostics() serviceServer?.close(new Error('Buddy Local Service is shutting down')) process.exit(failed ? 1 : exitCode) } @@ -117,13 +125,7 @@ async function runBuddyService(): Promise { const log = createRunEventLog({ conversationsDirectory: join(buddyHome, 'conversations'), database: openedDatabase, - onEvent: event => serviceServer?.notify(runNotifications.event.method, toPublicRunEvent(event)), - onEventDeliveryError: (error, event) => { - record({ event: 'run.notification_failed', level: 'warn', runId: event.runId, errorCode: readDiagnosticErrorCode(error) }) - process.stderr.write( - `Lexora Buddy run event notification failed: ${error.name} ${event.runId}#${event.sequence}\n`, - ) - }, + onObserverError: error => record({ event: 'run.observer_failed', level: 'warn', errorCode: readDiagnosticErrorCode(error) }), onFatalFailure: (error) => { record({ event: 'run.storage_failed', level: 'error', runId: error.runId, errorCode: error.code }) notifyFailure(readBuddyServiceFailureCode(error)) @@ -138,7 +140,18 @@ async function runBuddyService(): Promise { void shutdown(1) }, }) - defer(() => log.close()) + const delivery = log.onDidCommit((event) => { + try { + serviceServer?.notify(runNotifications.event.method, toPublicRunEvent(event)) + } + catch (error) { + record({ event: 'run.notification_failed', level: 'warn', runId: event.runId, errorCode: readDiagnosticErrorCode(error) }) + } + }) + defer(async () => { + await log.close() + delivery.dispose() + }) return log }, ['runtime.database']) await host.step('runtime.event_replay', () => eventLog!.replayAll()) @@ -150,6 +163,7 @@ async function runBuddyService(): Promise { eventLog: eventLog!, rpc: serviceServer, events, + lifecycle, }) defer(() => serviceHandle!.dispose()) }) @@ -158,6 +172,8 @@ async function runBuddyService(): Promise { catch (error) { notifyFailure(readBuddyServiceFailureCode(error)) await host.stop().catch(() => {}) + lifecycleDelivery.dispose() + stopLifecycleDiagnostics() serviceServer.close(new Error('Buddy Local Service startup failed')) closeDatabase() throw error diff --git a/apps/buddy/service/src/notifications/AttentionNotificationService.ts b/apps/buddy/service/src/notifications/AttentionNotificationService.ts index 08932fdb..14629cf7 100644 --- a/apps/buddy/service/src/notifications/AttentionNotificationService.ts +++ b/apps/buddy/service/src/notifications/AttentionNotificationService.ts @@ -3,6 +3,9 @@ import type { NotificationAttentionRepository, } from '../storage/notificationAttentionRepository' import type { ProviderModelStateRecord } from '../storage/providerModelStateRepository' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' const MODEL_UPDATE_NOTIFICATION_ID = 'local:model-source-parameters-updated' const RETENTION_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 @@ -69,7 +72,19 @@ export interface AttentionNotificationServiceOptions { now?: () => string } +export interface NotificationCommit { + readonly revision: number + readonly operationId: string + readonly reason: 'reconcile' | 'seen' | 'removed' + readonly changes: readonly { readonly id: string, readonly kind: 'added' | 'changed' | 'removed' }[] + readonly unseenCount: number +} + export class AttentionNotificationService { + readonly #commits = new Emitter(() => console.error('NOTIFICATION_OBSERVER_FAILED')) + readonly onDidCommit = this.#commits.event + #revision = 0 + #disposed = false readonly #attention: NotificationAttentionRepository readonly #listAutomationRuns: () => AutomationRunNotificationSource[] readonly #listModels: () => ProviderModelStateRecord[] @@ -83,33 +98,66 @@ export class AttentionNotificationService { } list(): AttentionNotificationList { - this.#reconcile() - const items = this.#attention.list() - .filter(isApplicationNotification) - .map(toNotification) - .sort(compareNotifications) - return { - items, - unseenCount: items.filter(item => item.attention === 'unseen').length, - } + this.reconcile() + return this.#read() + } + + get snapshot() { return copyEventSnapshot({ revision: this.#revision, ...this.#read() }) } + + reconcile(): void { + this.#commit('reconcile', () => this.#reconcile()) } markAllSeen(): AttentionNotificationList { - const now = this.#now() - this.#reconcile(now) - this.#attention.markAllSeen(now) - return this.list() + this.#commit('seen', () => { + const now = this.#now() + this.#reconcile(now) + this.#attention.markAllSeen(now) + }) + return this.#read() } markSeen(notificationId: string, revision: string): AttentionNotificationList { - const now = this.#now() - this.#reconcile(now) - this.#attention.markSeen(notificationId, revision, now) - return this.list() + this.#commit('seen', () => { + const now = this.#now() + this.#reconcile(now) + this.#attention.markSeen(notificationId, revision, now) + }) + return this.#read() } removeAutomationRun(runId: string): boolean { - return this.#attention.remove(`local:automation-run:${runId}`) + return this.#commit('removed', () => this.#attention.remove(`local:automation-run:${runId}`)) + } + + dispose(): void { + this.#disposed = true + this.#commits.dispose() + } + + #read(): AttentionNotificationList { + const items = this.#attention.list().filter(isApplicationNotification).map(toNotification).sort(compareNotifications) + return { items, unseenCount: items.filter(item => item.attention === 'unseen').length } + } + + #commit(reason: NotificationCommit['reason'], work: () => T): T { + if (this.#disposed) + throw new Error('NOTIFICATIONS_DISPOSED') + const before = new Map(this.#read().items.map(item => [item.id, item])) + try { + return work() + } + finally { + const result = this.#read() + const after = new Map(result.items.map(item => [item.id, item])) + const changes: NotificationCommit['changes'][number][] = [] + for (const id of new Set([...before.keys(), ...after.keys()])) { + if (JSON.stringify(before.get(id)) !== JSON.stringify(after.get(id))) + changes.push({ id, kind: before.has(id) ? after.has(id) ? 'changed' : 'removed' : 'added' }) + } + if (changes.length) + this.#commits.fire(copyEventSnapshot({ revision: ++this.#revision, operationId: randomUUID(), reason, changes, unseenCount: result.unseenCount })) + } } #reconcile(now = this.#now()): void { @@ -121,7 +169,10 @@ export class AttentionNotificationService { } #reconcileAutomationRuns(now: string): void { + const cutoff = Date.parse(now) - RETENTION_MILLISECONDS for (const run of this.#listAutomationRuns()) { + if (Date.parse(run.completedAt) < cutoff) + continue this.#attention.observe({ kind: `automation.run.${run.status}`, notificationId: `local:automation-run:${run.runId}`, diff --git a/apps/buddy/service/src/notifications/NotificationProjection.ts b/apps/buddy/service/src/notifications/NotificationProjection.ts new file mode 100644 index 00000000..d18e9074 --- /dev/null +++ b/apps/buddy/service/src/notifications/NotificationProjection.ts @@ -0,0 +1,106 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { Event } from '../../../shared/events/Emitter' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { AutomationService } from '../automations/AutomationService' +import type { RunEventObservation } from '../events/RunEventPorts' +import type { ProviderService } from '../providers/ProviderService' +import type { RunLifecycleService } from '../runs/RunLifecycleService' +import type { AttentionNotificationService } from './AttentionNotificationService' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' + +export class NotificationProjection { + readonly #subscriptions: EventSubscription[] + readonly #source: Pick + readonly #record: ApplicationDiagnosticReporter + readonly #changes = new Emitter<{ readonly status: 'ready' | 'pending' | 'degraded' | 'stopped' }>(() => console.error('NOTIFICATION_PROJECTION_OBSERVER_FAILED')) + readonly onDidChange: Event<{ readonly status: 'ready' | 'pending' | 'degraded' | 'stopped' }> = this.#changes.event + #status: 'ready' | 'pending' | 'degraded' | 'stopped' = 'ready' + #pending: Promise | undefined + #dirty = false + #stopping = false + + constructor(options: { + service: Pick + providers: Pick + automations: Pick + runs: RunEventObservation + lifecycle: Pick + record: ApplicationDiagnosticReporter + }) { + this.#source = options.service + this.#record = safeDiagnosticReporter(options.record) + this.#subscriptions = [ + options.providers.onDidCommit((event) => { + if (event.models.length) + this.reconcile() + }), + options.automations.onDidCommit((event) => { + if (event.facts.some(fact => fact.kind === 'occurrence.finished' || fact.kind === 'occurrence.deleted')) + this.reconcile() + }), + options.runs.onDidCommit((event) => { + if (event.type === 'run.completed' || event.type === 'run.failed') + this.reconcile() + }), + options.lifecycle.onDidReconcile(() => this.reconcile()), + ] + this.reconcile() + } + + get snapshot() { return Object.freeze({ status: this.#status }) } + + reconcile(): void { + if (this.#stopping) + return + this.#dirty = true + this.#schedule() + } + + #schedule(): void { + if (this.#pending) + return + this.#pending = Promise.resolve().then(() => { + while (this.#dirty) { + this.#dirty = false + for (let attempt = 0; attempt < 3; attempt++) { + try { + this.#source.reconcile() + this.#setStatus('ready') + break + } + catch { + if (attempt === 2) { + this.#setStatus('degraded') + this.#record({ event: 'notifications.projection.degraded', level: 'warn' }) + } + } + } + } + }).finally(() => { + this.#pending = undefined + if (this.#dirty) + this.#schedule() + }) + this.#setStatus('pending') + } + + async whenIdle(): Promise { + while (this.#pending) await this.#pending + } + + async dispose(): Promise { + this.#stopping = true + await this.whenIdle() + for (const subscription of this.#subscriptions) subscription.dispose() + this.#setStatus('stopped') + this.#changes.dispose() + } + + #setStatus(status: 'ready' | 'pending' | 'degraded' | 'stopped'): void { + if (this.#status === status) + return + this.#status = status + this.#changes.fire(this.snapshot) + } +} diff --git a/apps/buddy/service/src/notifications/__tests__/AttentionNotificationService.spec.ts b/apps/buddy/service/src/notifications/__tests__/AttentionNotificationService.spec.ts index c578a84e..5ac2c357 100644 --- a/apps/buddy/service/src/notifications/__tests__/AttentionNotificationService.spec.ts +++ b/apps/buddy/service/src/notifications/__tests__/AttentionNotificationService.spec.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' +import type { NotificationCommit } from '../AttentionNotificationService' import { afterEach, describe, expect, it } from 'vitest' import { openBuddyDatabase } from '../../storage/database' import { createNotificationAttentionRepository } from '../../storage/notificationAttentionRepository' @@ -13,6 +14,71 @@ afterEach(() => { }) describe('attentionNotificationService', () => { + it('emits effective content and lifecycle changes even at the same source revision, suppressing query and seen no-ops', () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + let models = [modelState()] + const service = new AttentionNotificationService({ attention: createNotificationAttentionRepository(database), listAutomationRuns: () => [], listModels: () => models, now: () => '2026-08-20T01:00:00.000Z' }) + const events: NotificationCommit[] = [] + service.onDidCommit(event => events.push(event)) + const first = service.list().items[0]! + service.list() + expect(events).toHaveLength(1) + models = [...models, { ...modelState(), modelId: 'another-model' }] + expect(service.list().items[0]).toMatchObject({ revision: first.revision, payload: { modelCount: 2 } }) + expect(events.at(-1)?.changes).toEqual([{ id: first.id, kind: 'changed' }]) + service.markSeen(first.id, 'older-revision') + expect(events).toHaveLength(2) + service.markSeen(first.id, first.revision) + service.markSeen(first.id, first.revision) + expect(events).toHaveLength(3) + models = [] + expect(service.list().items[0]).toMatchObject({ lifecycle: 'resolved', attention: 'seen', revision: first.revision }) + expect(events).toHaveLength(4) + expect(Object.isFrozen(events[0]?.changes)).toBe(true) + service.dispose() + }) + + it('keeps partial reconciliation facts when another source fails', () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const attention = createNotificationAttentionRepository(database) + const service = new AttentionNotificationService({ attention, listAutomationRuns: () => { + throw new Error('history unavailable') + }, listModels: () => [modelState()], now: () => '2026-08-20T01:00:00.000Z' }) + const events: NotificationCommit[] = [] + service.onDidCommit(event => events.push(event)) + expect(() => service.list()).toThrow('history unavailable') + expect(attention.list()).toHaveLength(1) + expect(events[0]?.changes[0]?.kind).toBe('added') + expect(() => service.list()).toThrow('history unavailable') + expect(events).toHaveLength(1) + service.dispose() + }) + + it('prunes expired history without resurrecting it on repeated reads or after source deletion', () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + let now = '2026-08-20T01:00:00.000Z' + let runs = [{ automationId: 'automation-1', automationName: 'Fixture', completedAt: '2026-08-20T00:00:00.000Z', conversationId: 'conversation-1', errorCode: null, runId: 'run-1', status: 'completed' as const }] + const service = new AttentionNotificationService({ attention: createNotificationAttentionRepository(database), listAutomationRuns: () => runs, listModels: () => [], now: () => now }) + const events: NotificationCommit[] = [] + service.onDidCommit(event => events.push(event)) + service.list() + now = '2026-08-29T00:00:00.000Z' + expect(service.list().items).toEqual([]) + service.list() + expect(events.flatMap(event => event.changes.map(change => change.kind))).toEqual(['added', 'removed']) + runs = [{ ...runs[0]!, completedAt: now, runId: 'run-2' }] + service.list() + runs = [] + expect(service.removeAutomationRun('run-2')).toBe(true) + expect(service.list().items).toEqual([]) + expect(service.removeAutomationRun('run-2')).toBe(false) + expect(events).toHaveLength(4) + service.dispose() + }) + it('counts only unseen revisions and never resolves work when marking notifications seen', () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) diff --git a/apps/buddy/service/src/notifications/__tests__/NotificationProjection.spec.ts b/apps/buddy/service/src/notifications/__tests__/NotificationProjection.spec.ts new file mode 100644 index 00000000..01f6bf7c --- /dev/null +++ b/apps/buddy/service/src/notifications/__tests__/NotificationProjection.spec.ts @@ -0,0 +1,84 @@ +import type { AutomationCommit } from '../../automations/AutomationEvents' +import type { BuddyRunEvent } from '../../events/BuddyRunEvent' +import type { ProviderCommit } from '../../providers/ProviderState' +import type { RunSqlReconciliation } from '../../runs/RunLifecycleService' +import { describe, expect, it } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' +import { openBuddyDatabase } from '../../storage/database' +import { createNotificationAttentionRepository } from '../../storage/notificationAttentionRepository' +import { AttentionNotificationService } from '../AttentionNotificationService' +import { NotificationProjection } from '../NotificationProjection' + +function sources() { + const providers = new Emitter(() => {}) + const automations = new Emitter(() => {}) + const runs = new Emitter(() => {}) + const lifecycle = new Emitter(() => {}) + return { providers, automations, runs, lifecycle, options: { providers: { onDidCommit: providers.event }, automations: { onDidCommit: automations.event }, runs: { onDidCommit: runs.event, state: 'open' as const }, lifecycle: { onDidReconcile: lifecycle.event } } } +} + +describe('notification projection', () => { + it('reconciles terminal sources without an RPC and stops receiving after drain', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const sources_ = sources() + const runs = [{ automationId: 'automation', automationName: 'Fixture', completedAt: '2026-09-28T00:00:00.000Z', conversationId: 'conversation', errorCode: null, runId: 'run', status: 'completed' as const }] + const service = new AttentionNotificationService({ attention: createNotificationAttentionRepository(database), listModels: () => [], listAutomationRuns: () => runs, now: () => '2026-09-28T00:00:01.000Z' }) + const projection = new NotificationProjection({ ...sources_.options, service, record: () => {} }) + try { + await projection.whenIdle() + expect(service.snapshot.unseenCount).toBe(1) + runs[0]!.automationName = 'Updated' + sources_.automations.fire({ operationId: 'operation', revision: 1, facts: [{ kind: 'occurrence.finished', automationId: 'automation', occurrenceId: 'occurrence', status: 'skipped', errorCode: null }] }) + await projection.dispose() + expect(service.snapshot.items[0]?.payload).toMatchObject({ automationName: 'Updated' }) + expect(projection.snapshot.status).toBe('stopped') + runs[0]!.automationName = 'After disposal' + sources_.lifecycle.fire({ runId: 'run', conversationId: 'conversation', branchId: 'branch', status: 'failed', errorCode: 'EVENT_LOG_FAILED', completedAt: '2026-09-28T00:00:02.000Z' }) + await Promise.resolve() + expect(service.snapshot.items[0]?.payload).toMatchObject({ automationName: 'Updated' }) + } + finally { + await projection.dispose() + service.dispose() + database.close() + } + }) + + it('drains a fact arriving between a completed reconciliation and its promise cleanup', async () => { + const source = sources() + let current = 'initial' + const applied: string[] = [] + const projection = new NotificationProjection({ ...source.options, service: { reconcile: () => { + applied.push(current) + if (current === 'initial') { + queueMicrotask(() => { + current = 'late commit' + source.lifecycle.fire({ runId: 'run', conversationId: 'conversation', branchId: 'branch', status: 'failed', errorCode: 'EVENT_LOG_FAILED', completedAt: '2026-09-28T00:00:02.000Z' }) + }) + } + } }, record: () => {} }) + await projection.whenIdle() + expect(applied).toEqual(['initial', 'late commit']) + expect(projection.snapshot.status).toBe('ready') + await projection.dispose() + }) + + it('bounds failed reconciliation and allows explicit recovery with no false current state', async () => { + const source = sources() + let unavailable = true + let attempts = 0 + const projection = new NotificationProjection({ ...source.options, service: { reconcile: () => { + attempts++ + if (unavailable) + throw new Error('storage unavailable') + } }, record: () => {} }) + await projection.whenIdle() + expect(projection.snapshot.status).toBe('degraded') + expect(attempts).toBe(3) + unavailable = false + projection.reconcile() + await projection.whenIdle() + expect(projection.snapshot.status).toBe('ready') + await projection.dispose() + }) +}) diff --git a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts index da29fdc5..792051e5 100644 --- a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts +++ b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts @@ -47,99 +47,108 @@ export class ToolAuthorizationService { resource: { network?: SandboxNetworkTarget, signal?: AbortSignal } = {}, ): Promise { const options = this.#options - const signal = resource.signal ? AbortSignal.any([resource.signal, run.signal]) : run.signal - signal.throwIfAborted() - await run.flushProjectedEvents() - const decision = await this.#engine.decide({ - access: declared.access, - approvalAvailable: options.approvalAvailable, - approvalPolicy: options.approvalPolicy, - approval: declared.approval, - arguments: event.input, - cwd: options.cwd, - forceAsk: declared.forceAsk, - requireApproval: declared.requireApproval, - shellBoundary: declared.shellBoundary, - grants: options.getGrants(), - owner: options.owner, - paths: declared.paths, - profile: options.executionProfile, - toolName: event.toolName, - }) - if (decision.type === 'deny') - return decision.code - - let approvedOnce = false - if (decision.type === 'ask') { - const approval = await options.approvalService.request({ + let authorized = false + options.executionPermissions?.release(run, event.toolCallId) + try { + const signal = resource.signal ? AbortSignal.any([resource.signal, run.signal]) : run.signal + signal.throwIfAborted() + await run.flushProjectedEvents() + const decision = await this.#engine.decide({ + access: declared.access, + approvalAvailable: options.approvalAvailable, + approvalPolicy: options.approvalPolicy, + approval: declared.approval, arguments: event.input, - automation: declared.approval?.automation, - browser: declared.approval?.browser, cwd: options.cwd, - kind: decision.kind, - network: resource.network, - paths: declared.approval?.paths ?? toPathReview(decision), - reuse: declared.approval?.reuse, - runId: run.runId, - runSignal: run.signal, - signal, - shell: decision.shell, - sandboxDirectory: decision.sandboxDirectory - ? { path: decision.sandboxDirectory.path, access: decision.sandboxDirectory.access, reason: decision.sandboxDirectory.reason } - : undefined, - summary: decision.summary, - systemAction: declared.approval?.systemAction, - toolCallId: event.toolCallId, + forceAsk: declared.forceAsk, + requireApproval: declared.requireApproval, + shellBoundary: declared.shellBoundary, + grants: options.getGrants(), + owner: options.owner, + paths: declared.paths, + profile: options.executionProfile, toolName: event.toolName, }) - if (approval.decision === 'denied') - return 'APPROVAL_DENIED' - approvedOnce = approval.decision === 'approved_once' - } - signal.throwIfAborted() - const validation = await declared.validateBeforeExecution?.() - if (validation) - return validation.reason - if (!resource.network && declared.paths?.length) { - try { - await options.executionPermissions?.authorize(run, event.toolCallId, { + if (decision.type === 'deny') + return decision.code + + let approvedOnce = false + if (decision.type === 'ask') { + const approval = await options.approvalService.request({ + arguments: event.input, + automation: declared.approval?.automation, + browser: declared.approval?.browser, cwd: options.cwd, - grants: options.getGrants(), - paths: declared.paths, - reviewedPaths: decision.type === 'ask' ? decision.paths : undefined, + kind: decision.kind, + network: resource.network, + paths: declared.approval?.paths ?? toPathReview(decision), + reuse: declared.approval?.reuse, + runId: run.runId, + runSignal: run.signal, + signal, + shell: decision.shell, + sandboxDirectory: decision.sandboxDirectory + ? { path: decision.sandboxDirectory.path, access: decision.sandboxDirectory.access, reason: decision.sandboxDirectory.reason } + : undefined, + summary: decision.summary, + systemAction: declared.approval?.systemAction, + toolCallId: event.toolCallId, + toolName: event.toolName, }) + if (approval.decision === 'denied') + return 'APPROVAL_DENIED' + approvedOnce = approval.decision === 'approved_once' } - catch (error) { - if (error instanceof PathClassificationError) - return error.code - throw error - } - } - if (decision.type === 'ask') { signal.throwIfAborted() - if (decision.sandboxDirectory) { - if (!options.applySandboxDirectory) - return 'DIRECTORY_GRANT_FAILED' + const validation = await declared.validateBeforeExecution?.() + if (validation) + return validation.reason + if (!resource.network && declared.paths?.length) { try { - await options.applySandboxDirectory(run, decision.sandboxDirectory) + await options.executionPermissions?.authorize(run, event.toolCallId, { + cwd: options.cwd, + grants: options.getGrants(), + paths: declared.paths, + reviewedPaths: decision.type === 'ask' ? decision.paths : undefined, + }) } - catch { - return 'SANDBOX_DIRECTORY_CHANGED' + catch (error) { + if (error instanceof PathClassificationError) + return error.code + throw error } } - if (decision.grant && approvedOnce) { - try { - if (!options.applyGrant) + if (decision.type === 'ask') { + signal.throwIfAborted() + if (decision.sandboxDirectory) { + if (!options.applySandboxDirectory) return 'DIRECTORY_GRANT_FAILED' - await options.applyGrant(decision.grant) + try { + await options.applySandboxDirectory(run, decision.sandboxDirectory) + } + catch { + return 'SANDBOX_DIRECTORY_CHANGED' + } } - catch { - return 'DIRECTORY_GRANT_FAILED' + if (decision.grant && approvedOnce) { + try { + if (!options.applyGrant) + return 'DIRECTORY_GRANT_FAILED' + await options.applyGrant(decision.grant) + } + catch { + return 'DIRECTORY_GRANT_FAILED' + } } } + signal.throwIfAborted() + authorized = true + return null + } + finally { + if (!authorized) + options.executionPermissions?.release(run, event.toolCallId) } - signal.throwIfAborted() - return null } } diff --git a/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts b/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts index f1d7e158..5171ca28 100644 --- a/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts +++ b/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts @@ -2,11 +2,26 @@ import type { BuddyExtensionRunContext } from '../agent/extensions/BuddyExtensio import type { DirectoryGrant } from '../directories/resolveGrantedPath' import type { PermissionPath } from './permissionContract' import { dirname } from 'node:path' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { classifyPath, PathClassificationError } from './classifyPath' import { createSensitivePathMatcher } from './sensitivePaths' +export interface ToolExecutionPermissionChange { + readonly revision: number + readonly runId: string + readonly toolCallId?: string + readonly kind: 'granted' | 'cleared' + readonly count: number +} + export class ToolExecutionPermissions { - readonly #runs = new WeakMap>() + readonly #runs = new Map>() + readonly #changes = new Emitter(() => console.error('TOOL_PERMISSION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false + readonly #releases = new Map void>() readonly #sensitive = createSensitivePathMatcher() async authorize(run: BuddyExtensionRunContext, toolCallId: string, input: { @@ -15,6 +30,9 @@ export class ToolExecutionPermissions { paths: readonly PermissionPath[] reviewedPaths?: readonly { path: string }[] }): Promise { + if (this.#disposed) + throw new Error('TOOL_PERMISSIONS_STOPPED') + input = copyEventSnapshot(input) const grants: DirectoryGrant[] = [] for (const [index, path] of input.paths.entries()) { run.signal.throwIfAborted() @@ -27,13 +45,41 @@ export class ToolExecutionPermissions { grants.push({ canonicalRoot: root, root, kind: 'granted', grantId: `run:${run.runId}:${toolCallId}:${grants.length}` }) } run.signal.throwIfAborted() + if (this.#disposed) + throw new Error('TOOL_PERMISSIONS_STOPPED') let calls = this.#runs.get(run) if (!calls) { calls = new Map() this.#runs.set(run, calls) - run.signal.addEventListener('abort', () => this.#runs.delete(run), { once: true }) + const release = () => { + run.signal.removeEventListener('abort', release) + this.#releases.delete(run) + const count = [...this.#runs.get(run)?.values() ?? []].reduce((count, grants) => count + grants.length, 0) + this.#runs.delete(run) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, runId: run.runId, kind: 'cleared', count })) + } + this.#releases.set(run, release) + run.signal.addEventListener('abort', release, { once: true }) } - calls.set(toolCallId, grants) + if (JSON.stringify(calls.get(toolCallId) ?? []) === JSON.stringify(grants)) + return + calls.set(toolCallId, copyEventSnapshot(grants)) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, runId: run.runId, toolCallId, kind: 'granted', count: grants.length })) + } + + release(run: BuddyExtensionRunContext, toolCallId: string): void { + const calls = this.#runs.get(run) + const grants = calls?.get(toolCallId) + if (!grants) + return + calls!.delete(toolCallId) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, runId: run.runId, toolCallId, kind: 'cleared', count: grants.length })) + } + + dispose(): void { + this.#disposed = true + for (const release of this.#releases.values()) release() + this.#changes.dispose() } get(run: BuddyExtensionRunContext | null, toolCallId: string): readonly DirectoryGrant[] { diff --git a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts index e3d72375..02df5830 100644 --- a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts @@ -81,6 +81,36 @@ describe('harness tool authorization', () => { return { approvals, approve, authorization, call, controller, directories, events, executionPermissions, grants, options, records, repository, run } } + it('revokes temporary tool grants when the required persistent application fails', async () => { + const f = fixture() + const authorization = new ToolAuthorizationService({ ...f.options, applyGrant: () => { + throw new Error('owner revoked') + } }) + const path = join(outside, 'preview.html') + await writeFile(path, 'Preview') + const event = f.call('lexora_browser_open', { entryPath: path }) + const pending = authorization.authorize(event, f.run, { access: 'render', paths: [{ path, mode: 'existing' }] }) + await f.approve('approved') + expect(await pending).toBe('DIRECTORY_GRANT_FAILED') + expect(f.executionPermissions.get(f.run, event.toolCallId)).toEqual([]) + }) + + it('isolates temporary tool grants and clears them at the run boundary', async () => { + const f = fixture() + const events: unknown[] = [] + f.executionPermissions.onDidChange(event => events.push(event)) + await f.executionPermissions.authorize(f.run, 'tool', { cwd: workspace, grants: f.grants, paths: [{ path: outside, mode: 'existing' }] }) + const snapshot = f.executionPermissions.get(f.run, 'tool') + expect(snapshot).toHaveLength(1) + expect(Reflect.set(snapshot[0]!, 'canonicalRoot', root)).toBe(false) + expect(Reflect.set(snapshot, '0', { canonicalRoot: root })).toBe(false) + expect(f.executionPermissions.get(f.run, 'other-tool')).toEqual([]) + f.controller.abort() + expect(f.executionPermissions.get(f.run, 'tool')).toEqual([]) + expect(events).toMatchObject([{ kind: 'granted', count: 1 }, { kind: 'cleared', count: 1 }]) + expect(JSON.stringify(events)).not.toContain(outside) + }) + it('queues concurrent network requests, reuses the destination across commands, and clears at run end', async () => { const f = fixture() const command = new AbortController() diff --git a/apps/buddy/service/src/permissions/observeSessionPermissions.ts b/apps/buddy/service/src/permissions/observeSessionPermissions.ts new file mode 100644 index 00000000..8844dcd2 --- /dev/null +++ b/apps/buddy/service/src/permissions/observeSessionPermissions.ts @@ -0,0 +1,21 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { Event } from '../../../shared/events/Emitter' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { SessionDirectoryGrantChange } from '../directories/SessionDirectoryGrants' +import type { SandboxDirectoryPermissionChange } from '../sandbox/SandboxDirectoryPermissions' +import type { ToolExecutionPermissionChange } from './ToolExecutionPermissions' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' + +export function observeSessionPermissions(sources: { + grants: Event + sandbox: Event + tools: Event +}, conversationId: string, report?: ApplicationDiagnosticReporter): EventSubscription { + const record = safeDiagnosticReporter(report) + const subscriptions = [ + sources.grants(event => record({ event: `directory.session.${event.kind}`, level: 'info', conversationId, revision: event.revision, count: event.count })), + sources.sandbox(event => record({ event: `directory.sandbox.${event.kind}`, level: 'info', conversationId, runId: event.runId, revision: event.revision, count: event.count })), + sources.tools(event => record({ event: `directory.tool.${event.kind}`, level: 'info', conversationId, runId: event.runId, toolCallId: event.toolCallId, revision: event.revision, count: event.count })), + ] + return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) } +} diff --git a/apps/buddy/service/src/pet/PetActionService.ts b/apps/buddy/service/src/pet/PetActionService.ts index ac98b5a7..f03ba584 100644 --- a/apps/buddy/service/src/pet/PetActionService.ts +++ b/apps/buddy/service/src/pet/PetActionService.ts @@ -1,8 +1,10 @@ +import type { PetExecuteSequenceResult } from '../../../shared/runtime/petProtocol' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { AppendBuddyRunEventInput } from '../events/BuddyRunEvent' import type { PetMacroId } from './petMacroCatalog' import { randomUUID } from 'node:crypto' - +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { petExecuteSequenceParamsSchema, petExecuteSequenceResultSchema, @@ -27,7 +29,21 @@ export interface ExecutePetActionInput { toolCallId?: string } +export type PetActionChange = Readonly & { + readonly operationId: string + readonly revision: number +} & ( + | { readonly phase: 'requested' | 'transport-unknown' } + | { readonly phase: 'host-confirmed', readonly result: Readonly } + | { readonly phase: 'progress-recorded' | 'progress-failed', readonly result: Readonly | null } +) + export class PetActionService { + readonly #changes = new Emitter(() => console.error('PET_ACTION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Set>() + #revision = 0 + #disposing: Promise | undefined readonly #eventSink?: PetActionEventSink readonly #peer: Pick @@ -36,20 +52,35 @@ export class PetActionService { this.#peer = options.peer } - async execute(input: ExecutePetActionInput) { + execute(input: ExecutePetActionInput): Promise { + if (this.#disposing) + return Promise.reject(new Error('PET_SERVICE_STOPPED')) + const accepted = { ...input } + const pending = Promise.resolve().then(() => this.#execute(accepted)).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + return pending + } + + async #execute(input: ExecutePetActionInput): Promise { + const identity = { ...input, operationId: randomUUID() } const request = petExecuteSequenceParamsSchema.parse(compilePetMacro( input.macro, `pet_${randomUUID()}`, )) - let result + let result: PetExecuteSequenceResult + let confirmed: PetExecuteSequenceResult | null = null + this.#changes.fire(copyEventSnapshot({ ...identity, phase: 'requested', revision: ++this.#revision })) try { result = petExecuteSequenceResultSchema.parse(await this.#peer.request( 'host.pet.executeSequence', request, PET_HOST_TIMEOUT_MS, )) + confirmed = result + this.#changes.fire(copyEventSnapshot({ ...identity, phase: 'host-confirmed', result, revision: ++this.#revision })) } catch { + this.#changes.fire(copyEventSnapshot({ ...identity, phase: 'transport-unknown', revision: ++this.#revision })) result = petExecuteSequenceResultSchema.parse({ code: 'PET_UNAVAILABLE', completedSteps: 0, @@ -58,22 +89,34 @@ export class PetActionService { } if (input.runId && input.toolCallId && this.#eventSink) { - await this.#eventSink({ - payload: { - macro: input.macro, - presentation: createPetToolPresentation({ - arguments: { macro: input.macro }, - result: { details: { macro: input.macro, status: result.status } }, + try { + await this.#eventSink({ + payload: { + macro: input.macro, + presentation: createPetToolPresentation({ + arguments: { macro: input.macro }, + result: { details: { macro: input.macro, status: result.status } }, + toolName: PET_TOOL_NAME, + }), + status: result.status, + toolCallId: input.toolCallId, toolName: PET_TOOL_NAME, - }), - status: result.status, - toolCallId: input.toolCallId, - toolName: PET_TOOL_NAME, - }, - runId: input.runId, - type: 'tool.updated', - }) + }, + runId: input.runId, + type: 'tool.updated', + }) + this.#changes.fire(copyEventSnapshot({ ...identity, phase: 'progress-recorded', result: confirmed, revision: ++this.#revision })) + } + catch (error) { + this.#changes.fire(copyEventSnapshot({ ...identity, phase: 'progress-failed', result: confirmed, revision: ++this.#revision })) + throw error + } } return result } + + dispose(): Promise { + this.#disposing ??= Promise.allSettled([...this.#pending]).then(() => this.#changes.dispose()) + return this.#disposing + } } diff --git a/apps/buddy/service/src/pet/__tests__/PetActionService.spec.ts b/apps/buddy/service/src/pet/__tests__/PetActionService.spec.ts index b184cce3..01963e2b 100644 --- a/apps/buddy/service/src/pet/__tests__/PetActionService.spec.ts +++ b/apps/buddy/service/src/pet/__tests__/PetActionService.spec.ts @@ -1,6 +1,10 @@ +import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic' +import type { PetExecuteSequenceResult } from '../../../../shared/runtime/petProtocol' import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import type { PetActionChange } from '../PetActionService' import { describe, expect, it, vi } from 'vitest' - +import { applicationDiagnosticSchema } from '../../../../shared/diagnostics/applicationDiagnostic' +import { observePetActionDiagnostics } from '../observePetActionDiagnostics' import { PetActionService } from '../PetActionService' import { classifyPetTool, PET_TOOL_NAME } from '../petToolContract' @@ -15,6 +19,92 @@ function createPeer(result: unknown): RuntimeRpcPeerContract { } describe('petActionService', () => { + it.each([ + { status: 'completed', completedSteps: 2 }, + { status: 'failed', completedSteps: 1, code: 'PET_STEP_FAILED' }, + { status: 'interrupted', completedSteps: 1, reasonCode: 'admission.preemptedByHigherPriorityPlan' }, + ] satisfies PetExecuteSequenceResult[])('preserves a confirmed $status result in safe diagnostics', async (result) => { + const service = new PetActionService({ peer: createPeer(result), eventSink: () => {} }) + const facts: PetActionChange[] = [] + const diagnostics: ApplicationDiagnostic[] = [] + service.onDidChange(change => facts.push(change)) + observePetActionDiagnostics(service, event => diagnostics.push(applicationDiagnosticSchema.parse(event))) + await expect(service.execute({ macro: 'awaitApproval', runId: 'run-1', toolCallId: 'tool-1' })).resolves.toEqual(result) + expect(facts.map(change => change.phase)).toEqual(['requested', 'host-confirmed', 'progress-recorded']) + expect(diagnostics.map(event => event.event)).toEqual(['pet.action.requested', `pet.action.host_confirmed.${result.status}`, `pet.action.progress_recorded.${result.status}`]) + expect(diagnostics[1]).toMatchObject({ level: result.status === 'failed' ? 'warn' : 'info', count: result.completedSteps, runId: 'run-1', toolCallId: 'tool-1' }) + expect(diagnostics[1]!.errorCode).toBe(result.status === 'failed' ? result.code : undefined) + expect(new Set(diagnostics.map(event => event.operationId)).size).toBe(1) + expect(JSON.stringify(diagnostics)).not.toContain('awaitApproval') + expect(Object.isFrozen(facts[1]) && 'result' in facts[1]! && Object.isFrozen(facts[1]!.result)).toBe(true) + await service.dispose() + }) + + it('keeps transport loss unknown even after recording the legacy tool response', async () => { + const peer = createPeer(null) + vi.mocked(peer.request).mockRejectedValue(new Error('private transport details')) + const service = new PetActionService({ peer, eventSink: () => {} }) + const facts: PetActionChange[] = [] + const diagnostics: ApplicationDiagnostic[] = [] + service.onDidChange(change => facts.push(change)) + observePetActionDiagnostics(service, event => diagnostics.push(applicationDiagnosticSchema.parse(event))) + await service.execute({ macro: 'thinking', runId: 'run-1', toolCallId: 'tool-1' }) + expect(facts.map(change => change.phase)).toEqual(['requested', 'transport-unknown', 'progress-recorded']) + expect(facts.at(-1)).toMatchObject({ phase: 'progress-recorded', result: null }) + expect(diagnostics.map(event => event.event)).toEqual(['pet.action.requested', 'pet.action.transport_unknown', 'pet.action.progress_recorded.unknown']) + expect(diagnostics.every(event => event.count === undefined && event.errorCode === undefined)).toBe(true) + expect(JSON.stringify(diagnostics)).not.toContain('private transport details') + await service.dispose() + }) + + it('preserves host success while propagating a required progress write failure', async () => { + const failure = new Error('private writer failure') + const result = { status: 'completed', completedSteps: 1 } as const + const service = new PetActionService({ peer: createPeer(result), eventSink: () => { + throw failure + } }) + const facts: PetActionChange[] = [] + const diagnostics: ApplicationDiagnostic[] = [] + service.onDidChange(change => facts.push(change)) + observePetActionDiagnostics(service, event => diagnostics.push(applicationDiagnosticSchema.parse(event))) + await expect(service.execute({ macro: 'thinking', runId: 'run-1', toolCallId: 'tool-1' })).rejects.toBe(failure) + expect(facts.at(-1)).toMatchObject({ phase: 'progress-failed', result }) + expect(diagnostics.at(-1)).toMatchObject({ event: 'pet.action.progress_failed.completed', level: 'warn' }) + expect(diagnostics.filter(event => event.event === 'pet.action.host_confirmed.completed')).toHaveLength(1) + expect(diagnostics.some(event => event.event.endsWith('.failed') || event.event === 'pet.action.transport_unknown')).toBe(false) + expect(JSON.stringify(diagnostics)).not.toContain('private writer failure') + await service.dispose() + }) + + it('stops admission and drains the host response and accepted progress write before disposing its source', async () => { + const host = Promise.withResolvers() + const progress = Promise.withResolvers() + const progressStarted = Promise.withResolvers() + const peer = createPeer(null) + vi.mocked(peer.request).mockReturnValue(host.promise) + const service = new PetActionService({ peer, eventSink: async () => { + progressStarted.resolve() + await progress.promise + } }) + const phases: string[] = [] + service.onDidChange(change => phases.push(change.phase)) + const executing = service.execute({ macro: 'thinking', runId: 'run-1', toolCallId: 'tool-1' }) + let disposed = false + const stopping = service.dispose().then(() => { + disposed = true + }) + await expect(service.execute({ macro: 'thinking' })).rejects.toThrow('PET_SERVICE_STOPPED') + expect(disposed).toBe(false) + host.resolve({ status: 'completed', completedSteps: 1 }) + await progressStarted.promise + expect(disposed).toBe(false) + expect(phases).toEqual(['requested', 'host-confirmed']) + progress.resolve() + await Promise.all([executing, stopping]) + expect(disposed).toBe(true) + expect(phases).toEqual(['requested', 'host-confirmed', 'progress-recorded']) + }) + it('compiles approval into a bounded primitive and restores idle afterwards', async () => { const peer = createPeer({ status: 'completed', completedSteps: 1 }) const events: unknown[] = [] diff --git a/apps/buddy/service/src/pet/observePetActionDiagnostics.ts b/apps/buddy/service/src/pet/observePetActionDiagnostics.ts new file mode 100644 index 00000000..549b063c --- /dev/null +++ b/apps/buddy/service/src/pet/observePetActionDiagnostics.ts @@ -0,0 +1,20 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { PetActionService } from './PetActionService' + +export function observePetActionDiagnostics(service: PetActionService, report: ApplicationDiagnosticReporter) { + return service.onDidChange((change) => { + const result = 'result' in change ? change.result : null + const outcome = 'result' in change ? `.${result?.status ?? 'unknown'}` : '' + report({ + event: `pet.action.${change.phase.replaceAll('-', '_')}${outcome}`, + component: 'runtime.pet', + level: change.phase === 'transport-unknown' || change.phase === 'progress-failed' || result?.status === 'failed' ? 'warn' : 'info', + operationId: change.operationId, + revision: change.revision, + ...(change.runId ? { runId: change.runId } : {}), + ...(change.toolCallId ? { toolCallId: change.toolCallId } : {}), + ...(change.phase === 'host-confirmed' ? { count: change.result.completedSteps } : {}), + ...(result?.status === 'failed' ? { errorCode: result.code } : {}), + }) + }) +} diff --git a/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts b/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts new file mode 100644 index 00000000..77c63d25 --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts @@ -0,0 +1,26 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' +import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' + +export interface ExtensionCapabilityProjection { + readonly id: string + readonly conversationId: string + readonly revision: number + readonly status: 'accepted' | 'unavailable' + readonly descriptors: EventSnapshot +} + +interface InvocationIdentity { + readonly invocationId: string + readonly extensionId: string + readonly conversationId: string + readonly runId: string +} + +export type ExtensionAgentFact + = | { readonly kind: 'capabilities', readonly projection: ExtensionCapabilityProjection } + | { readonly kind: 'capabilities-released', readonly projectionId: string, readonly conversationId: string } + | (InvocationIdentity & { readonly kind: 'invocation', readonly stage: 'started' | 'returned' | 'settled', readonly outcome?: 'completed' | 'failed' | 'cancelled', readonly durationMs?: number }) + | (InvocationIdentity & { readonly kind: 'request', readonly requestId: string, readonly method: ExtensionAgentMethod, readonly stage: 'accepted' | 'finished', readonly handler?: 'completed' | 'failed', readonly response?: 'returned' | 'failed' | 'cancelled', readonly durationMs?: number }) + +export type ExtensionAgentChange = ExtensionAgentFact & { readonly revision: number } diff --git a/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts b/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts new file mode 100644 index 00000000..77fe1494 --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts @@ -0,0 +1,229 @@ +import type { ListenerErrorHandler } from '../../../shared/events/Emitter' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' +import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' +import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' +import type { JsonValue } from '../../../shared/workbench/workbenchState' +import type { BuddyCapability, BuddyCapabilityContext } from '../agent/extensions/BuddyCapability' +import type { ExtensionAgentChange, ExtensionAgentFact, ExtensionCapabilityProjection } from './ExtensionAgentEvents' +import type { ExtensionAgentHandlers, ExtensionCapabilityContext, ExtensionInvocationScope } from './extensionAgentHandlers' +import { createHash, randomUUID } from 'node:crypto' +import { defineTool } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' +import { Check } from 'typebox/value' +import { z } from 'zod' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { extensionAgentDescriptorSchema, extensionAgentRequestSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent' +import { extensionAgentCapabilities } from '../../../shared/extensions/extensionAgentCapabilities' +import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' +import { extensionIdSchema } from '../../../shared/extensions/extensionManifest' + +interface Invocation { + scope: ExtensionInvocationScope + requests: Map + pending: Set> + accepting: boolean + settled: Promise + settle: () => void +} +interface ProjectionState { + snapshot: ExtensionCapabilityProjection + request: number + release: () => void +} +interface ExtensionAgentRuntimeOptions { + rpc: RuntimeRpcPeerContract + handlers: ExtensionAgentHandlers + context: (scope: ExtensionInvocationScope) => Omit + onObserverError?: ListenerErrorHandler +} + +export class ExtensionAgentRuntime { + readonly #options: ExtensionAgentRuntimeOptions + readonly #invocations = new Map() + readonly #projections = new Map() + readonly #changes: Emitter + readonly onDidChange + readonly #stop = new AbortController() + #revision = 0 + #disposing: Promise | undefined + + constructor(options: ExtensionAgentRuntimeOptions) { + this.#options = options + this.#changes = new Emitter(options.onObserverError ?? (() => console.error('EXTENSION_AGENT_OBSERVER_FAILED'))) + this.onDidChange = this.#changes.event + } + + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, projections: [...this.#projections.values()].map(state => state.snapshot), activeInvocations: [...this.#invocations.values()].map(invocation => ({ invocationId: invocation.scope.invocationId, extensionId: invocation.scope.extensionId, runId: invocation.scope.runId, conversationId: invocation.scope.conversationId, accepting: invocation.accepting, pendingRequests: invocation.pending.size })) }) + } + + bind(): () => void { + return this.#options.rpc.onRequest(extensionAgentRpc.request, (input, signal) => this.#request(input, signal)) + } + + async capabilities(context: BuddyCapabilityContext): Promise { + context.signal.throwIfAborted() + this.#stop.signal.throwIfAborted() + const projection = this.#projection(context) + const request = ++projection.request + try { + const descriptors = z.array(extensionAgentDescriptorSchema.extend({ id: extensionIdSchema })).parse(await this.#options.rpc.request(extensionAgentRpc.list, {}, 10000, AbortSignal.any([context.signal, this.#stop.signal]))) + context.signal.throwIfAborted() + this.#stop.signal.throwIfAborted() + this.#acceptProjection(projection, request, 'accepted', descriptors) + return descriptors.map(descriptor => this.#capability(context, descriptor)) + } + catch { + context.signal.throwIfAborted() + this.#stop.signal.throwIfAborted() + this.#acceptProjection(projection, request, 'unavailable', []) + return [] + } + } + + dispose(): Promise { + if (this.#disposing) + return this.#disposing + this.#disposing = Promise.resolve().then(async () => { + for (const projection of this.#projections.values()) projection.release() + await Promise.allSettled([...this.#invocations.values()].map(invocation => invocation.settled)) + this.#changes.dispose() + }) + this.#stop.abort() + return this.#disposing + } + + #capability(context: BuddyCapabilityContext, input: ExtensionAgentDescriptor): BuddyCapability { + const descriptor: EventSnapshot = copyEventSnapshot(input) + const prefix = `plugin_${createHash('sha256').update(descriptor.id).digest('hex').slice(0, 12)}` + const names = new Map(descriptor.agent.tools.map(tool => [tool.id, `lexora_plugin_${createHash('sha256').update(tool.id).digest('hex').slice(0, 16)}`])) + const instructions = descriptor.agent.instructions.replace(/\{\{([^}]+)\}\}/g, (_match, id: string) => names.get(id) ?? id) + const tools = descriptor.agent.tools.map(tool => ({ ...tool, name: names.get(tool.id)!, schema: Type.Object(Object.fromEntries(Object.entries(tool.parameters.properties).map(([key, property]) => { + const options = { description: property.description } + const value = property.type === 'string' ? Type.String({ ...options, maxLength: 32768 }) : property.type === 'boolean' ? Type.Boolean(options) : Type.Number(options) + return [key, tool.parameters.required.includes(key) ? value : Type.Optional(value)] + })), { additionalProperties: false }) })) + return { + classify: event => tools.some(tool => tool.name === event.toolName) ? { access: 'read', paths: [] } : null, + extension: { + name: `lexora-${prefix}`, + factory: (pi) => { + for (const tool of tools) { + pi.registerTool(defineTool({ + name: tool.name, + label: `${descriptor.name} · ${tool.title}`, + description: tool.description, + promptGuidelines: instructions ? [instructions] : [], + parameters: tool.schema, + execute: async (_toolCallId, input, signal) => { + const runId = context.getRunId() + if (this.#stop.signal.aborted || context.signal.aborted || !runId || !Check(tool.schema, input)) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + const controller = new AbortController() + const abort = AbortSignal.any([context.signal, this.#stop.signal, controller.signal, ...signal ? [signal] : [], AbortSignal.timeout(120000)]) + const invocationId = randomUUID() + let settle!: () => void + const invocation: Invocation = { scope: { conversationId: context.conversationId, runId, extensionId: descriptor.id, invocationId, signal: abort }, requests: new Map(), pending: new Set(), accepting: true, settled: new Promise(resolve => settle = resolve), settle: () => settle() } + this.#invocations.set(invocationId, invocation) + const identity = { invocationId, extensionId: descriptor.id, conversationId: context.conversationId, runId } + const startedAt = performance.now() + let outcome: 'completed' | 'failed' | 'cancelled' = 'completed' + this.#publish({ kind: 'invocation', stage: 'started', ...identity }) + try { + abort.throwIfAborted() + const result = extensionJsonSchema.parse(await this.#options.rpc.request(extensionAgentRpc.invoke, { extensionId: descriptor.id, revision: descriptor.revision, configurationRevision: descriptor.configurationRevision, invocationId, tool: tool.id, input }, 125000, abort)) + abort.throwIfAborted() + return { content: [{ type: 'text', text: JSON.stringify(result) }], details: { ok: true, result } } + } + catch (error) { + outcome = abort.aborted ? 'cancelled' : 'failed' + const code = error instanceof Error ? error.message.match(/EXTENSION_[A-Z_]+/)?.[0] : null + return { content: [{ type: 'text', text: code ?? 'EXTENSION_AGENT_FAILED' }], details: { ok: false, result: null }, isError: true } + } + finally { + invocation.accepting = false + this.#publish({ kind: 'invocation', stage: 'returned', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) + controller.abort() + await Promise.allSettled([...invocation.pending]) + this.#invocations.delete(invocationId) + this.#publish({ kind: 'invocation', stage: 'settled', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) + invocation.settle() + } + }, + })) + } + pi.on('tool_result', (event) => { + if (tools.some(tool => tool.name === event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) + return { isError: true } + }) + }, + }, + } + } + + async #request(raw: unknown, requestSignal?: AbortSignal): Promise { + const input = extensionAgentRequestSchema.parse(raw) + const invocation = this.#invocations.get(input.invocationId) + if (!invocation?.accepting || this.#stop.signal.aborted) + throw new Error('EXTENSION_AGENT_UNAVAILABLE') + const signal = requestSignal ? AbortSignal.any([invocation.scope.signal, requestSignal]) : invocation.scope.signal + signal.throwIfAborted() + const contract = extensionAgentCapabilities[input.method] + const context = this.#options.context({ ...invocation.scope, signal }) + const requests = invocation.requests.get(input.method) ?? { calls: 0, active: 0 } + if (contract.limits && (requests.calls >= contract.limits.calls || requests.active >= contract.limits.concurrent)) + throw new Error(contract.limits.error) + requests.calls++ + requests.active++ + invocation.requests.set(input.method, requests) + const callNumber = requests.calls + const identity = { invocationId: invocation.scope.invocationId, extensionId: invocation.scope.extensionId, conversationId: invocation.scope.conversationId, runId: invocation.scope.runId, requestId: randomUUID(), method: input.method } + const startedAt = performance.now() + let handler: 'completed' | 'failed' = 'failed' + const pending = Promise.resolve().then(async () => { + signal.throwIfAborted() + const result = await this.#options.handlers[input.method](input.params, { ...context, callNumber }) + handler = 'completed' + signal.throwIfAborted() + return result + }).finally(() => { + requests.active-- + invocation.pending.delete(pending) + this.#publish({ kind: 'request', stage: 'finished', ...identity, handler, response: signal.aborted ? 'cancelled' : handler === 'completed' ? 'returned' : 'failed', durationMs: Math.round(performance.now() - startedAt) }) + }) + invocation.pending.add(pending) + this.#publish({ kind: 'request', stage: 'accepted', ...identity }) + return pending + } + + #projection(context: BuddyCapabilityContext): ProjectionState { + const existing = this.#projections.get(context.signal) + if (existing) + return existing + const id = randomUUID() + const release = () => { + context.signal.removeEventListener('abort', release) + this.#projections.delete(context.signal) + this.#publish({ kind: 'capabilities-released', projectionId: id, conversationId: context.conversationId }) + } + const projection: ProjectionState = { snapshot: { id, conversationId: context.conversationId, revision: 0, status: 'unavailable', descriptors: [] }, request: 0, release } + this.#projections.set(context.signal, projection) + context.signal.addEventListener('abort', release, { once: true }) + return projection + } + + #acceptProjection(projection: ProjectionState, request: number, status: ExtensionCapabilityProjection['status'], descriptors: ExtensionAgentDescriptor[]): void { + if (projection.request !== request) + return + if (projection.snapshot.revision && projection.snapshot.status === status && JSON.stringify(projection.snapshot.descriptors) === JSON.stringify(descriptors)) + return + projection.snapshot = copyEventSnapshot({ ...projection.snapshot, revision: projection.snapshot.revision + 1, status, descriptors }) + this.#publish({ kind: 'capabilities', projection: projection.snapshot }) + } + + #publish(fact: ExtensionAgentFact): void { + this.#changes.fire(copyEventSnapshot({ ...fact, revision: ++this.#revision })) + } +} diff --git a/apps/buddy/service/src/plugins/PluginAuthoringService.ts b/apps/buddy/service/src/plugins/PluginAuthoringService.ts new file mode 100644 index 00000000..27c0b747 --- /dev/null +++ b/apps/buddy/service/src/plugins/PluginAuthoringService.ts @@ -0,0 +1,115 @@ +import type { ListenerErrorHandler } from '../../../shared/events/Emitter' +import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' +import type { BuddyCapabilityContext } from '../agent/extensions/BuddyCapability' +import { Buffer } from 'node:buffer' +import { open, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import { zipSync } from 'fflate/browser' +import { readExtensionDirectory } from '../../../platform/extensions/extensionFiles' +import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { EXTENSION_BUILD_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema } from '../../../shared/extensions/extensionAuthoring' +import { resolveGrantedPath } from '../directories/resolveGrantedPath' + +export interface PluginAuthoringChange { + readonly kind: 'package-written' | 'review-requested' | 'review-failed' + readonly operationId: string + readonly extensionId: string + readonly conversationId: string + readonly runId?: string + readonly bytes: number + readonly errorCode?: string +} + +export class PluginAuthoringService { + readonly #peer: Pick + readonly #changes: Emitter + readonly onDidChange + readonly #pending = new Set & { ok: boolean }>>() + readonly #stop = new AbortController() + #disposing: Promise | undefined + + constructor(peer: Pick, onObserverError: ListenerErrorHandler = () => console.error('PLUGIN_AUTHORING_OBSERVER_FAILED')) { + this.#peer = peer + this.#changes = new Emitter(onObserverError) + this.onDidChange = this.#changes.event + } + + build(context: BuddyCapabilityContext, toolCallId: string, input: { source: string, output: string, review?: boolean }, signal?: AbortSignal): Promise & { ok: boolean }> { + if (this.#stop.signal.aborted) + return Promise.resolve({ ok: false, code: 'EXTENSION_HOST_STOPPED', diagnostics: [] }) + const pending = Promise.resolve().then(() => this.#build(context, toolCallId, input, signal)).finally(() => this.#pending.delete(pending)) + this.#pending.add(pending) + return pending + } + + dispose(): Promise { + if (this.#disposing) + return this.#disposing + this.#disposing = Promise.resolve().then(async () => { + await Promise.allSettled([...this.#pending]) + this.#changes.dispose() + }) + this.#stop.abort() + return this.#disposing + } + + async #build(context: BuddyCapabilityContext, toolCallId: string, input: { source: string, output: string, review?: boolean }, signal?: AbortSignal): Promise & { ok: boolean }> { + const diagnostics: string[] = [] + const operationId = crypto.randomUUID() + const runId = context.getRunId() + try { + const abort = AbortSignal.any([context.signal, this.#stop.signal, ...signal ? [signal] : []]) + abort.throwIfAborted() + const grants = context.getExecutionGrants?.(toolCallId) ?? context.grants + const source = await resolveGrantedPath(grants, resolve(context.cwd, input.source), 'existing') + const output = await resolveGrantedPath(grants, resolve(context.cwd, input.output), 'create') + if (!output.canonicalPath.endsWith('.lexora-extension') || containsCanonicalPath(source.canonicalPath, output.canonicalPath)) + throw new Error('EXTENSION_OUTPUT_INVALID') + const files = await readExtensionDirectory(source.canonicalPath) + const archive = Buffer.from(zipSync(Object.fromEntries(files), { level: 6 })).toString('base64') + const result = extensionBuildResultSchema.parse(await this.#peer.request(EXTENSION_BUILD_RPC, { archive }, 45000, abort)) + diagnostics.push(...result.diagnostics) + if (!result.ok) + return { ok: false, code: result.code, diagnostics } + abort.throwIfAborted() + const currentOutput = await resolveGrantedPath(grants, resolve(context.cwd, input.output), 'create') + if (currentOutput.canonicalPath !== output.canonicalPath) + throw new Error('EXTENSION_OUTPUT_CHANGED') + const bytes = Buffer.from(result.archive, 'base64') + const handle = await open(output.canonicalPath, 'wx', 0o600) + try { + await handle.writeFile(bytes, { signal: abort }) + await handle.sync() + } + catch (error) { + await handle.close() + await rm(output.canonicalPath, { force: true }) + throw error + } + finally { await handle.close() } + const identity = { operationId, extensionId: result.id, conversationId: context.conversationId, runId, bytes: bytes.length } + this.#changes.fire(copyEventSnapshot({ kind: 'package-written', ...identity })) + let reviewRequested = false + let reviewError: string | undefined + if (input.review) { + try { + abort.throwIfAborted() + this.#peer.notify(EXTENSION_REVIEW_REQUEST, { path: output.canonicalPath }) + reviewRequested = true + this.#changes.fire(copyEventSnapshot({ kind: 'review-requested', ...identity })) + } + catch { + reviewError = abort.aborted ? 'EXTENSION_REVIEW_CANCELLED' : 'EXTENSION_REVIEW_REQUEST_FAILED' + this.#changes.fire(copyEventSnapshot({ kind: 'review-failed', ...identity, errorCode: reviewError })) + } + } + return { ok: true, id: result.id, name: result.name, author: result.author, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested, ...(reviewError ? { reviewError } : {}), installation: reviewRequested ? 'review_requested' : reviewError ? 'review_failed' : 'not_requested', runtimeTested: false } + } + catch (error) { + const code = (error as { code?: string }).code ?? (error instanceof Error ? error.message : '') + return { ok: false, code: /^[A-Z][A-Z_]+$/.test(code) ? code : 'EXTENSION_BUILD_FAILED', diagnostics } + } + } +} diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts new file mode 100644 index 00000000..d926cfa1 --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts @@ -0,0 +1,117 @@ +import type { ToolDefinition } from '@earendil-works/pi-coding-agent' +import type { ExtensionAgentDescriptor } from '../../../../shared/extensions/extensionAgent' +import type { RuntimeRequestHandler, RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import type { BuddyCapabilityContext } from '../../agent/extensions/BuddyCapability' +import type { ExtensionAgentChange } from '../ExtensionAgentEvents' +import type { ExtensionAgentHandlers } from '../extensionAgentHandlers' +import { deferred } from '@buddy-tests/deferred' +import { describe, expect, it } from 'vitest' +import { extensionAgentRpc } from '../../../../shared/extensions/extensionAgent' +import { ExtensionAgentRuntime } from '../ExtensionAgentRuntime' + +const descriptor: ExtensionAgentDescriptor = { id: 'tests.reader', name: 'Fixture', revision: 'package-revision', configurationRevision: 'config-revision', agent: { instructions: 'fixture-private-instructions', tools: [{ id: 'tests.reader.read', title: 'Read', description: 'fixture-private-description', parameters: { type: 'object', properties: {}, required: [], additionalProperties: false } }] } } +function fixture() { + const requests = new Map() + const stop = new AbortController() + const context: BuddyCapabilityContext = { conversationId: 'conversation-1', cwd: '/fixture-private', executionProfile: 'read_only', grants: [], getRunId: () => 'run-1', sessionMode: 'interactive', signal: stop.signal } + let descriptors: ExtensionAgentDescriptor[] = [descriptor] + let unavailable = false + let invoke: (input: unknown, signal?: AbortSignal) => Promise = async () => null + const handlers: ExtensionAgentHandlers = { 'task.get': async () => null, 'task.rename': async () => null, 'models.generateText': async () => null } + const rpc: RuntimeRpcPeerContract = { + notify() {}, + close() {}, + onNotification: () => () => {}, + onRequest: (method, handler) => { + requests.set(method, handler) + + return () => { + requests.delete(method) + } + }, + request: async (method, input, _timeout, signal) => { + if (method === extensionAgentRpc.list) { + if (unavailable) + throw new Error('fixture-private-unavailable') + return descriptors + } + return invoke(input, signal) + }, + } + const runtime = new ExtensionAgentRuntime({ rpc, handlers, context: scope => ({ ...scope, model: { providerId: 'provider-1', modelId: 'model-1' } }) }) + const facts: ExtensionAgentChange[] = [] + runtime.onDidChange(event => facts.push(event)) + runtime.bind() + return { runtime, context, stop, facts, handlers, setDescriptors: (value: ExtensionAgentDescriptor[]) => { + descriptors = value + }, unavailable: (value: boolean) => { + unavailable = value + }, invoke: (value: typeof invoke) => { + invoke = value + }, request: (input: unknown) => Promise.resolve(requests.get(extensionAgentRpc.request)!(input)), tool: async () => { + const capabilities = await runtime.capabilities(context) + let tool!: ToolDefinition + await capabilities[0]!.extension.factory({ registerTool: (value: ToolDefinition) => { + tool = value + }, on() {} } as never) + return () => tool.execute('tool-call-1', {}, undefined, undefined, {} as never) + } } +} + +describe('extension agent capability and invocation lifetimes', () => { + it('distinguishes an accepted empty catalog from an unavailable source and releases its scope snapshot', async () => { + const f = fixture() + f.setDescriptors([]) + expect(await f.runtime.capabilities(f.context)).toEqual([]) + expect(f.runtime.snapshot.projections[0]).toMatchObject({ status: 'accepted', revision: 1, descriptors: [] }) + f.unavailable(true) + expect(await f.runtime.capabilities(f.context)).toEqual([]) + expect(f.runtime.snapshot.projections[0]).toMatchObject({ status: 'unavailable', revision: 2, descriptors: [] }) + f.unavailable(false) + await f.runtime.capabilities(f.context) + expect(f.facts.filter(fact => fact.kind === 'capabilities')).toHaveLength(3) + f.stop.abort() + expect(f.runtime.snapshot.projections).toEqual([]) + expect(f.facts.at(-1)?.kind).toBe('capabilities-released') + await f.runtime.dispose() + }) + + it('retains invocation ownership until accepted child handlers actually finish and fences new requests after return', async () => { + const f = fixture() + const childStarted = deferred() + const childCommitted = deferred() + let child!: Promise + let invocationId = '' + f.handlers['task.rename'] = async () => { + childStarted.resolve() + await childCommitted.promise + return { title: 'fixture-private-committed' } + } + f.invoke(async (raw) => { + invocationId = (raw as { invocationId: string }).invocationId + child = f.request({ invocationId, method: 'task.rename', params: { title: 'fixture-private-title' } }).catch(error => error) + await childStarted.promise + return { done: true } + }) + const execute = await f.tool() + const executing = execute() + await childStarted.promise + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(f.facts).toContainEqual(expect.objectContaining({ kind: 'invocation', stage: 'returned', outcome: 'completed' })) + expect(f.facts.some(fact => fact.kind === 'invocation' && fact.stage === 'settled')).toBe(false) + expect(f.runtime.snapshot.activeInvocations).toMatchObject([{ invocationId, accepting: false, pendingRequests: 1 }]) + await expect(f.request({ invocationId, method: 'task.get', params: null })).rejects.toThrow('EXTENSION_AGENT_UNAVAILABLE') + const stopping = f.runtime.dispose() + childCommitted.resolve() + await child + await executing + await stopping + const finished = f.facts.find(fact => fact.kind === 'request' && fact.stage === 'finished') + expect(finished).toMatchObject({ handler: 'completed', response: 'cancelled' }) + expect(f.facts.at(-1)).toMatchObject({ kind: 'invocation', stage: 'settled', invocationId }) + expect(f.runtime.snapshot.activeInvocations).toEqual([]) + expect(JSON.stringify(f.facts.filter(fact => fact.kind !== 'capabilities'))).not.toContain('fixture-private') + }) +}) diff --git a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts index 77514349..b5904315 100644 --- a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts +++ b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts @@ -8,6 +8,7 @@ import { buildExtensionPackage } from '../../../../platform/extensions/buildExte import { compileExtensionSource } from '../../../../platform/extensions/compileExtensionSource' import { unpackExtension } from '../../../../platform/extensions/extensionFiles' import { createPluginAuthoringCapability } from '../pluginAuthoringCapability' +import { PluginAuthoringService } from '../PluginAuthoringService' const roots: string[] = [] afterEach(async () => { @@ -24,12 +25,19 @@ async function fixture() { const notifications: unknown[] = [] let tool: ToolDefinition | undefined let identityTool: ToolDefinition | undefined - const capability = createPluginAuthoringCapability({ conversationId: 'task', cwd: root, executionProfile: 'workspace_write', getRunId: () => 'run', grants: [{ kind: 'workspace', canonicalRoot: root, root, grantId: 'workspace' }], sessionMode: 'interactive', signal: new AbortController().signal }, { - request: async (_method, input, _timeout, signal) => buildExtensionPackage(input, async (files, manifest, _signal, report) => compileExtensionSource(files, manifest, report), signal!), - notify: (_method, input) => { + let rejectReview = false + const peer = { + request: async (_method: string, input: unknown, _timeout?: number, signal?: AbortSignal) => buildExtensionPackage(input, async (files, manifest, _signal, report) => compileExtensionSource(files, manifest, report), signal!), + notify: (_method: string, input: unknown) => { + if (rejectReview) + throw new Error('fixture-private-review-failure') notifications.push(input) }, - }) + } + const builder = new PluginAuthoringService(peer) + const facts: unknown[] = [] + builder.onDidChange(event => facts.push(event)) + const capability = createPluginAuthoringCapability({ conversationId: 'task', cwd: root, executionProfile: 'workspace_write', getRunId: () => 'run', grants: [{ kind: 'workspace', canonicalRoot: root, root, grantId: 'workspace' }], sessionMode: 'interactive', signal: new AbortController().signal }, peer, builder) await capability.extension.factory({ registerTool(value: ToolDefinition) { tool = value if (value.name === 'lexora_plugin_identity') @@ -40,6 +48,8 @@ async function fixture() { source, notifications, capability, + facts, + rejectReview: () => { rejectReview = true }, identity: async (input: unknown) => await identityTool!.execute('identity', input as never, undefined, undefined, {} as never), execute: async (input: unknown) => await tool!.execute('call', input as never, undefined, undefined, {} as never) as { details: { ok: boolean, code?: string, packagePath?: string, installed?: boolean, runtimeTested?: boolean } }, } @@ -54,6 +64,16 @@ it('writes an authorized installable output and requests review without installi expect(f.notifications).toEqual([{ path: output }]) }) +it('keeps the committed package and reports review failure separately when notification fails', async () => { + const f = await fixture() + f.rejectReview() + const result = await f.execute({ source: 'source', output: 'example.lexora-extension', review: true }) + expect(result.details).toMatchObject({ ok: true, installation: 'review_failed', reviewRequested: false, reviewError: 'EXTENSION_REVIEW_REQUEST_FAILED' }) + expect(unpackExtension(await readFile(join(f.root, 'example.lexora-extension'))).has('view.js')).toBe(true) + expect(f.facts).toMatchObject([{ kind: 'package-written', extensionId: 'local.example' }, { kind: 'review-failed', extensionId: 'local.example', errorCode: 'EXTENSION_REVIEW_REQUEST_FAILED' }]) + expect(JSON.stringify(f.facts)).not.toMatch(/fixture-private|packagePath|example\.lexora-extension/) +}) + it('preserves existing files and rejects output outside grants or inside source', async () => { const f = await fixture() const output = join(f.root, 'existing.lexora-extension') diff --git a/apps/buddy/service/src/plugins/extensionAgentHandlers.ts b/apps/buddy/service/src/plugins/extensionAgentHandlers.ts new file mode 100644 index 00000000..3996f45b --- /dev/null +++ b/apps/buddy/service/src/plugins/extensionAgentHandlers.ts @@ -0,0 +1,25 @@ +import type { z } from 'zod' +import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' +import type { JsonValue } from '../../../shared/workbench/workbenchState' +import { extensionAgentCapabilities } from '../../../shared/extensions/extensionAgentCapabilities' + +export interface ExtensionInvocationScope { + conversationId: string + runId: string + extensionId: string + invocationId: string + signal: AbortSignal +} +export interface ExtensionCapabilityContext extends ExtensionInvocationScope { + model: { providerId: string, modelId: string } + callNumber: number +} +export type ExtensionAgentHandlers = Record Promise> + +export function extensionAgentHandler(method: K, execute: (input: z.output<(typeof extensionAgentCapabilities)[K]['input']>, context: ExtensionCapabilityContext) => z.output<(typeof extensionAgentCapabilities)[K]['output']> | Promise>) { + return async (input: unknown, context: ExtensionCapabilityContext): Promise => { + const contract = extensionAgentCapabilities[method] + const params = contract.input.parse(input) as z.output<(typeof extensionAgentCapabilities)[K]['input']> + return contract.output.parse(await execute(params, context)) + } +} diff --git a/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts b/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts new file mode 100644 index 00000000..0850b1a7 --- /dev/null +++ b/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts @@ -0,0 +1,16 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ExtensionAgentRuntime } from './ExtensionAgentRuntime' + +export function observeExtensionAgentDiagnostics(service: ExtensionAgentRuntime, report: ApplicationDiagnosticReporter) { + return service.onDidChange((change) => { + if (change.kind === 'capabilities') { + report({ event: `plugins.capabilities.${change.projection.status}`, level: change.projection.status === 'unavailable' ? 'warn' : 'info', operationId: change.projection.id, conversationId: change.projection.conversationId, revision: change.projection.revision, count: change.projection.descriptors.length }) + } + else if (change.kind === 'invocation') { + report({ event: `plugins.invocation.${change.stage}`, level: change.outcome === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, runId: change.runId, conversationId: change.conversationId, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.outcome === 'failed' ? { errorCode: 'EXTENSION_AGENT_FAILED' } : change.outcome === 'cancelled' ? { errorCode: 'EXTENSION_AGENT_CANCELLED' } : {}) }) + } + else if (change.kind === 'request') { + report({ event: `plugins.request.${change.stage}`, level: change.response === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, requestId: change.requestId, runId: change.runId, conversationId: change.conversationId, method: change.method, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.handler === 'failed' ? { errorCode: 'EXTENSION_REQUEST_FAILED' } : change.response === 'cancelled' ? { errorCode: 'EXTENSION_RESPONSE_CANCELLED' } : {}) }) + } + }) +} diff --git a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts index 33d9f24d..2582a91c 100644 --- a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts +++ b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts @@ -1,17 +1,11 @@ import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { BuddyCapability, BuddyCapabilityContext } from '../agent/extensions/BuddyCapability' -import { Buffer } from 'node:buffer' -import { open, rm } from 'node:fs/promises' -import { resolve } from 'node:path' +import type { PluginAuthoringService } from './PluginAuthoringService' import { defineTool } from '@earendil-works/pi-coding-agent' -import { zipSync } from 'fflate/browser' import { Type } from 'typebox' import { Check } from 'typebox/value' -import { readExtensionDirectory } from '../../../platform/extensions/extensionFiles' -import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' -import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionCapabilitiesSchema, extensionIdentityRequestSchema, extensionIdentitySchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesSchema, extensionIdentityRequestSchema, extensionIdentitySchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' import { workbenchCapabilityKinds } from '../../../shared/workbench/workbenchContributionCatalog' -import { resolveGrantedPath } from '../directories/resolveGrantedPath' const name = 'lexora_plugin_build' const inspectName = 'lexora_plugin_inspect' @@ -32,7 +26,7 @@ const parameters = Type.Object({ review: Type.Optional(Type.Boolean({ description: 'Show the user an installation review after building. Never installs automatically.' })), }, { additionalProperties: false }) -export function createPluginAuthoringCapability(context: BuddyCapabilityContext, peer: Pick): BuddyCapability { +export function createPluginAuthoringCapability(context: BuddyCapabilityContext, peer: Pick, builder: PluginAuthoringService): BuddyCapability { return { classify(event) { if (event.toolName === identityName) @@ -102,48 +96,9 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, parameters, description: 'Validate and compile a self-contained Lexora TS/JS plugin using the isolated built-in compiler, and write an installable package. Returns the actual id, name, author (empty means unsigned), version and diagnostics. Author signature is the manifest author field, independent of the ID prefix; changing an author must not regenerate or rename the ID. Does not run plugin code, install dependencies or install plugins. Optionally opens the user installation review.', async execute(toolCallId, input, signal) { - const diagnostics: string[] = [] - try { - if (!Check(parameters, input)) - throw new Error('VALIDATION_FAILED') - const abort = signal ? AbortSignal.any([signal, context.signal]) : context.signal - abort.throwIfAborted() - const grants = context.getExecutionGrants?.(toolCallId) ?? context.grants - const source = await resolveGrantedPath(grants, resolve(context.cwd, input.source), 'existing') - const output = await resolveGrantedPath(grants, resolve(context.cwd, input.output), 'create') - if (!output.canonicalPath.endsWith('.lexora-extension')) - throw new Error('EXTENSION_OUTPUT_INVALID') - if (containsCanonicalPath(source.canonicalPath, output.canonicalPath)) - throw new Error('EXTENSION_OUTPUT_INVALID') - const files = await readExtensionDirectory(source.canonicalPath) - const archive = Buffer.from(zipSync(Object.fromEntries(files), { level: 6 })).toString('base64') - const result = extensionBuildResultSchema.parse(await peer.request(EXTENSION_BUILD_RPC, { archive }, 45000, abort)) - diagnostics.push(...result.diagnostics) - if (!result.ok) - return response({ ok: false, code: result.code, diagnostics }) - abort.throwIfAborted() - const currentOutput = await resolveGrantedPath(grants, resolve(context.cwd, input.output), 'create') - if (currentOutput.canonicalPath !== output.canonicalPath) - throw new Error('EXTENSION_OUTPUT_CHANGED') - const handle = await open(output.canonicalPath, 'wx', 0o600) - try { - await handle.writeFile(Buffer.from(result.archive, 'base64'), { signal: abort }) - await handle.sync() - } - catch (error) { - await handle.close() - await rm(output.canonicalPath, { force: true }) - throw error - } - finally { await handle.close() } - if (input.review) - peer.notify(EXTENSION_REVIEW_REQUEST, { path: output.canonicalPath }) - return response({ ok: true, id: result.id, name: result.name, author: result.author, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested: input.review === true, installation: input.review ? 'review_requested' : 'not_requested', runtimeTested: false }) - } - catch (error) { - const code = (error as { code?: string }).code ?? (error instanceof Error ? error.message : '') - return response({ ok: false, code: /^[A-Z][A-Z_]+$/.test(code) ? code : 'EXTENSION_BUILD_FAILED', diagnostics }) - } + if (!Check(parameters, input)) + return response({ ok: false, code: 'VALIDATION_FAILED', diagnostics: [] }) + return response(await builder.build(context, toolCallId, input, signal)) }, })) pi.on('tool_result', (event) => { diff --git a/apps/buddy/service/src/providers/AuthInteractionService.ts b/apps/buddy/service/src/providers/AuthInteractionService.ts index f71a8660..94b6610f 100644 --- a/apps/buddy/service/src/providers/AuthInteractionService.ts +++ b/apps/buddy/service/src/providers/AuthInteractionService.ts @@ -1,80 +1,92 @@ -import type { - AuthEvent, - AuthInteraction, - AuthPrompt, -} from '@earendil-works/pi-ai' +import type { AuthEvent, AuthInteraction, AuthPrompt } from '@earendil-works/pi-ai' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { ProviderAuthChallenge } from './providerSchemas' import { randomUUID } from 'node:crypto' - -import { providerNotifications } from '../../../shared/providers/providerApi' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { providerAuthChallengeSchema } from './providerSchemas' export interface AuthInteractionServiceOptions { - notify: (method: string, params: unknown) => void openExternal?: (url: string) => Promise } - export interface LoginInteractionHandle { interaction: AuthInteraction loginId: string } - +export interface AuthInteractionChange { + readonly revision: number + readonly loginId: string + readonly providerId: string + readonly kind: 'started' | 'challenge-opened' | 'challenge-closed' | 'ended' + readonly challengeId?: string + readonly challengeType?: ProviderAuthChallenge['type'] + readonly outcome?: 'responded' | 'cancelled' | 'completed' | 'failed' +} interface LoginSession { - challenges: Set + challenges: Map controller: AbortController providerId: string } - interface PendingPrompt { loginId: string prompt: AuthPrompt + dispose: () => void reject: (error: Error) => void resolve: (value: string) => void } export class AuthInteractionService { - readonly #notify: AuthInteractionServiceOptions['notify'] + readonly #changes = new Emitter(() => console.error('AUTH_INTERACTION_OBSERVER_FAILED')) + readonly #challenges = new Emitter>(() => console.error('AUTH_CHALLENGE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly onDidChallenge = this.#challenges.event readonly #openExternal?: AuthInteractionServiceOptions['openExternal'] readonly #sessions = new Map() readonly #challengeSessions = new Map() readonly #pendingPrompts = new Map() + #revision = 0 + #disposed = false - constructor(options: AuthInteractionServiceOptions) { - this.#notify = options.notify + constructor(options: AuthInteractionServiceOptions = {}) { this.#openExternal = options.openExternal } + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, logins: [...this.#sessions].map(([loginId, session]) => ({ loginId, providerId: session.providerId, challenges: [...session.challenges].map(([challengeId, type]) => ({ challengeId, type })) })) }) + } + beginLogin(providerId: string): LoginInteractionHandle { + if (this.#disposed) + throw new ProviderLoginCancelledError() const loginId = randomUUID() - const session: LoginSession = { - challenges: new Set(), - controller: new AbortController(), - providerId, - } + const session: LoginSession = { challenges: new Map(), controller: new AbortController(), providerId } this.#sessions.set(loginId, session) - return { - loginId, - interaction: { - signal: session.controller.signal, - notify: event => this.#handleEvent(loginId, event), - prompt: prompt => this.#handlePrompt(loginId, prompt), - }, - } + this.#publish({ loginId, providerId, kind: 'started' }) + return { loginId, interaction: { + signal: session.controller.signal, + notify: event => this.#handleEvent(loginId, event), + prompt: prompt => this.#handlePrompt(loginId, prompt), + } } } - completeLogin(loginId: string): void { + completeLogin(loginId: string, outcome: 'cancelled' | 'completed' | 'failed' = 'completed'): void { const session = this.#sessions.get(loginId) if (!session) return this.#sessions.delete(loginId) - for (const challengeId of session.challenges) { + const changes: AuthInteractionChange[] = [] + for (const [challengeId, challengeType] of session.challenges) { this.#challengeSessions.delete(challengeId) const pending = this.#pendingPrompts.get(challengeId) - if (pending) { - pending.reject(new ProviderLoginCancelledError()) - this.#pendingPrompts.delete(challengeId) - } + this.#pendingPrompts.delete(challengeId) + pending?.dispose() + pending?.reject(new ProviderLoginCancelledError()) + changes.push({ revision: ++this.#revision, loginId, providerId: session.providerId, kind: 'challenge-closed', challengeId, challengeType, outcome }) } + session.challenges.clear() + session.controller.abort(new ProviderLoginCancelledError()) + changes.push({ revision: ++this.#revision, loginId, providerId: session.providerId, kind: 'ended', outcome }) + this.#changes.fireBatch(changes.map(change => Object.freeze(change))) } respondToPrompt(challengeId: string, value: string): void { @@ -83,24 +95,28 @@ export class AuthInteractionService { throw new UnknownAuthChallengeError() if (pending.prompt.type === 'select' && !pending.prompt.options.some(option => option.id === value)) throw new InvalidAuthChallengeResponseError() - - this.#pendingPrompts.delete(challengeId) - this.#removeChallenge(challengeId, pending.loginId) - pending.resolve(value) + this.#finishPrompt(challengeId, 'responded', value) } cancelLogin(challengeId: string): void { const loginId = this.#challengeSessions.get(challengeId) - const session = loginId ? this.#sessions.get(loginId) : undefined - if (!loginId || !session) + if (!loginId || !this.#sessions.has(loginId)) throw new UnknownAuthChallengeError() - session.controller.abort(new ProviderLoginCancelledError()) - this.completeLogin(loginId) + this.completeLogin(loginId, 'cancelled') + } + + dispose(): void { + this.#disposed = true + for (const loginId of this.#sessions.keys()) this.completeLogin(loginId, 'cancelled') + this.#changes.dispose() + this.#challenges.dispose() } #handlePrompt(loginId: string, prompt: AuthPrompt): Promise { const session = this.#requireSession(loginId) - const challengeId = this.#registerChallenge(loginId, session) + if (prompt.signal?.aborted) + return Promise.reject(new ProviderLoginCancelledError()) + const challengeId = randomUUID() const challenge = providerAuthChallengeSchema.parse({ challengeId, providerId: session.providerId, @@ -110,48 +126,51 @@ export class AuthInteractionService { ...(prompt.type === 'select' ? { options: prompt.options } : {}), }) const response = new Promise((resolve, reject) => { - this.#pendingPrompts.set(challengeId, { loginId, prompt, reject, resolve }) - const abort = () => { - if (!this.#pendingPrompts.delete(challengeId)) - return - this.#removeChallenge(challengeId, loginId) - reject(new ProviderLoginCancelledError()) + const abort = () => this.#finishPrompt(challengeId, 'cancelled') + const dispose = () => { + session.controller.signal.removeEventListener('abort', abort) + prompt.signal?.removeEventListener('abort', abort) } + const owned = prompt.type === 'select' ? { ...prompt, options: prompt.options.map(option => ({ ...option })) } : prompt + this.#pendingPrompts.set(challengeId, { loginId, prompt: owned, reject, resolve, dispose }) session.controller.signal.addEventListener('abort', abort, { once: true }) prompt.signal?.addEventListener('abort', abort, { once: true }) }) - this.#emitChallenge(challenge) + this.#registerChallenge(loginId, session, challenge) return response } #handleEvent(loginId: string, event: AuthEvent): void { const session = this.#requireSession(loginId) - const challengeId = this.#registerChallenge(loginId, session) - const challenge = toChallenge(challengeId, session.providerId, event) - this.#emitChallenge(challenge) - const externalUrl = event.type === 'auth_url' - ? event.url - : event.type === 'device_code' - ? event.verificationUri - : null - if (externalUrl && this.#openExternal) - void this.#openExternal(externalUrl).catch(() => {}) + const challenge = toChallenge(randomUUID(), session.providerId, event) + this.#registerChallenge(loginId, session, challenge) + const url = event.type === 'auth_url' ? event.url : event.type === 'device_code' ? event.verificationUri : null + if (url && this.#openExternal && this.#sessions.get(loginId) === session) + void this.#openExternal(url).catch(() => {}) } - #emitChallenge(challenge: ProviderAuthChallenge): void { - this.#notify(providerNotifications.authChallenge.method, challenge) - } - - #registerChallenge(loginId: string, session: LoginSession): string { - const challengeId = randomUUID() - session.challenges.add(challengeId) - this.#challengeSessions.set(challengeId, loginId) - return challengeId + #registerChallenge(loginId: string, session: LoginSession, challenge: ProviderAuthChallenge): void { + session.challenges.set(challenge.challengeId, challenge.type) + this.#challengeSessions.set(challenge.challengeId, loginId) + this.#publish({ loginId, providerId: session.providerId, kind: 'challenge-opened', challengeId: challenge.challengeId, challengeType: challenge.type }) + if (this.#challengeSessions.has(challenge.challengeId)) + this.#challenges.fire(copyEventSnapshot(challenge)) } - #removeChallenge(challengeId: string, loginId: string): void { + #finishPrompt(challengeId: string, outcome: 'responded' | 'cancelled', value?: string): void { + const pending = this.#pendingPrompts.get(challengeId) + if (!pending) + return + this.#pendingPrompts.delete(challengeId) this.#challengeSessions.delete(challengeId) - this.#sessions.get(loginId)?.challenges.delete(challengeId) + const session = this.#sessions.get(pending.loginId) + session?.challenges.delete(challengeId) + pending.dispose() + if (outcome === 'responded') + pending.resolve(value!) + else pending.reject(new ProviderLoginCancelledError()) + if (session) + this.#publish({ loginId: pending.loginId, providerId: session.providerId, kind: 'challenge-closed', challengeId, challengeType: pending.prompt.type, outcome }) } #requireSession(loginId: string): LoginSession { @@ -160,6 +179,10 @@ export class AuthInteractionService { throw new ProviderLoginCancelledError() return session } + + #publish(change: Omit): void { + this.#changes.fire(Object.freeze({ ...change, revision: ++this.#revision })) + } } export class ProviderLoginCancelledError extends Error { diff --git a/apps/buddy/service/src/providers/HostCredentialStore.ts b/apps/buddy/service/src/providers/HostCredentialStore.ts index e2d77a24..b9436b16 100644 --- a/apps/buddy/service/src/providers/HostCredentialStore.ts +++ b/apps/buddy/service/src/providers/HostCredentialStore.ts @@ -5,6 +5,9 @@ import type { CredentialStore, } from '@earendil-works/pi-ai' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { credentialMutationResultSchema, credentialProviderListResultSchema, @@ -23,34 +26,82 @@ export class HostCredentialStoreError extends Error { } } +export interface CredentialObservation { + readonly revision: number + readonly providerId: string + readonly presence: 'present' | 'absent' | 'unknown' + readonly type: 'api_key' | 'oauth' | null +} +export type CredentialChange = { readonly kind: 'observation', readonly previous: CredentialObservation | null, readonly current: CredentialObservation } + | { readonly kind: 'write-confirmed' | 'delete-confirmed', readonly operationId: string, readonly providerId: string } + | { readonly kind: 'store-availability', readonly revision: number, readonly status: 'known' | 'unknown' } + export class HostCredentialStore implements CredentialStore { + readonly #pending = new Set>() + #stopping = false readonly #peer: RuntimeRpcPeerContract readonly #providerLocks = new Map>() + readonly #observations = new Map() + readonly #observedRequests = new Map() + readonly #changes = new Emitter(() => console.error('CREDENTIAL_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #request = 0 + #availability: 'known' | 'unknown' = 'unknown' + #availabilityRequest = 0 + + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, availability: this.#availability, providers: [...this.#observations.values()] }) + } constructor(peer: RuntimeRpcPeerContract) { this.#peer = peer } - async read(providerId: string, options?: AuthOperationOptions): Promise { - options?.signal?.throwIfAborted() - const response = credentialReadResultSchema.parse(await this.#peer.request( - 'host.credentials.read', - { providerId }, - )) - assertSuccess(response) - options?.signal?.throwIfAborted() - return response.value === null ? undefined : credentialSchema.parse(response.value) as Credential + read(providerId: string, options?: AuthOperationOptions): Promise { + return this.#operate(() => this.#read(providerId, options)) } - async list(options?: AuthOperationOptions): Promise { + async #read(providerId: string, options?: AuthOperationOptions): Promise { options?.signal?.throwIfAborted() - const response = credentialProviderListResultSchema.parse(await this.#peer.request( - 'host.credentials.list', - {}, - )) - assertSuccess(response) + const request = ++this.#request + try { + const response = credentialReadResultSchema.parse(await this.#peer.request('host.credentials.read', { providerId })) + assertSuccess(response) + const value = response.value === null ? undefined : credentialSchema.parse(response.value) as Credential + this.#observe(providerId, value?.type ?? null, request) + options?.signal?.throwIfAborted() + return value + } + catch (error) { + if (!options?.signal?.aborted) + this.#observe(providerId, 'unknown', request) + throw error + } + } + + list(options?: AuthOperationOptions): Promise { + return this.#operate(() => this.#list(options)) + } + + async #list(options?: AuthOperationOptions): Promise { options?.signal?.throwIfAborted() - return response.providers + const request = ++this.#request + try { + const response = credentialProviderListResultSchema.parse(await this.#peer.request('host.credentials.list', {})) + assertSuccess(response) + const providers = new Map(response.providers.map(provider => [provider.providerId, provider.type])) + for (const providerId of new Set([...providers.keys(), ...this.#observations.keys()])) + this.#observe(providerId, providers.get(providerId) ?? null, request) + this.#observeAvailability('known', request) + options?.signal?.throwIfAborted() + return copyEventSnapshot(response.providers) + } + catch (error) { + if (!options?.signal?.aborted) + this.#observeAvailability('unknown', request) + throw error + } } modify( @@ -60,18 +111,24 @@ export class HostCredentialStore implements CredentialStore { ): Promise { return this.#withProviderLock(providerId, async () => { options?.signal?.throwIfAborted() - const current = await this.read(providerId, options) + const current = await this.#read(providerId, options) const next = await operation(structuredClone(current)) options?.signal?.throwIfAborted() if (next === undefined) return current const credential = credentialSchema.parse(next) as Credential - const response = credentialMutationResultSchema.parse(await this.#peer.request( - 'host.credentials.write', - { providerId, credential }, - )) - assertSuccess(response) + const request = ++this.#request + try { + const response = credentialMutationResultSchema.parse(await this.#peer.request('host.credentials.write', { providerId, credential })) + assertSuccess(response) + } + catch (error) { + this.#observe(providerId, 'unknown', request) + throw error + } + this.#observe(providerId, credential.type, request) + this.#changes.fire(Object.freeze({ kind: 'write-confirmed', providerId, operationId: randomUUID() })) return credential }) } @@ -79,17 +136,69 @@ export class HostCredentialStore implements CredentialStore { delete(providerId: string, options?: AuthOperationOptions): Promise { return this.#withProviderLock(providerId, async () => { options?.signal?.throwIfAborted() - const response = credentialMutationResultSchema.parse(await this.#peer.request( - 'host.credentials.delete', - { providerId }, - )) - assertSuccess(response) + const request = ++this.#request + try { + const response = credentialMutationResultSchema.parse(await this.#peer.request('host.credentials.delete', { providerId })) + assertSuccess(response) + } + catch (error) { + this.#observe(providerId, 'unknown', request) + throw error + } + this.#observe(providerId, null, request) + this.#changes.fire(Object.freeze({ kind: 'delete-confirmed', providerId, operationId: randomUUID() })) }) } + async dispose(): Promise { + this.#stopping = true + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + this.#changes.dispose() + } + + #operate(operation: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new HostCredentialStoreError('CREDENTIAL_STORE_UNAVAILABLE')) + const pending = Promise.withResolvers() + this.#pending.add(pending.promise) + try { + void operation().then(pending.resolve, pending.reject) + } + catch (error) { pending.reject(error) } + void pending.promise.then(() => this.#pending.delete(pending.promise), () => this.#pending.delete(pending.promise)) + return pending.promise + } + + #observe(providerId: string, type: 'api_key' | 'oauth' | 'unknown' | null, request: number): void { + if (request < (this.#observedRequests.get(providerId) ?? 0)) + return + this.#observedRequests.set(providerId, request) + const previous = this.#observations.get(providerId) ?? null + const presence = type === 'unknown' ? 'unknown' : type ? 'present' : 'absent' + const credentialType = type === 'unknown' ? null : type + if (previous?.presence === presence && previous.type === credentialType) + return + const current: CredentialObservation = Object.freeze({ revision: ++this.#revision, providerId, presence, type: credentialType }) + this.#observations.set(providerId, current) + this.#changes.fire(Object.freeze({ kind: 'observation', previous, current })) + } + + #observeAvailability(status: 'known' | 'unknown', request: number): void { + if (request < this.#availabilityRequest) + return + this.#availabilityRequest = request + if (this.#availability === status) + return + this.#availability = status + this.#changes.fire(Object.freeze({ kind: 'store-availability', revision: ++this.#revision, status })) + } + #withProviderLock(providerId: string, operation: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new HostCredentialStoreError('CREDENTIAL_STORE_UNAVAILABLE')) const previous = this.#providerLocks.get(providerId) ?? Promise.resolve() - const result = previous.catch(() => {}).then(operation) + const result = this.#operate(() => previous.catch(() => {}).then(operation)) const tail = result.then(() => {}, () => {}) this.#providerLocks.set(providerId, tail) void tail.then(() => { diff --git a/apps/buddy/service/src/providers/ModelsDevCatalog.ts b/apps/buddy/service/src/providers/ModelsDevCatalog.ts index ca3075d3..4de47aa5 100644 --- a/apps/buddy/service/src/providers/ModelsDevCatalog.ts +++ b/apps/buddy/service/src/providers/ModelsDevCatalog.ts @@ -1,5 +1,6 @@ import type { Api, Model } from '@earendil-works/pi-ai' import { BUDDY_THINKING_LEVELS } from '../../../shared/conversation/modelSelection' +import { freezeEventSnapshot } from '../../../shared/events/eventSnapshot' export interface CatalogModel { id: string @@ -80,10 +81,13 @@ export class ModelsDevCatalog implements ModelMetadataCatalog { } if (!this.#models.length) throw new Error('Empty models.dev catalog') + freezeEventSnapshot(this.#providers) + freezeEventSnapshot(this.#models) + for (const models of this.#byProvider.values()) freezeEventSnapshot(models) } getProviders() { - return this.#providers + return this.#providers as ReadonlyArray<{ id: string, name: string }> } getModels(providerId?: string): readonly CatalogModel[] { diff --git a/apps/buddy/service/src/providers/ProviderCredentialStatus.ts b/apps/buddy/service/src/providers/ProviderCredentialStatus.ts index 2d644545..67c8ae82 100644 --- a/apps/buddy/service/src/providers/ProviderCredentialStatus.ts +++ b/apps/buddy/service/src/providers/ProviderCredentialStatus.ts @@ -5,6 +5,7 @@ export interface ProviderCredentialSource { } export interface ProviderCredentialStatus { + readonly availability: 'known' | 'unknown' list: () => Promise listOrEmpty: () => Promise } @@ -12,11 +13,33 @@ export interface ProviderCredentialStatus { export function createProviderCredentialStatus( source: ProviderCredentialSource, ): ProviderCredentialStatus { + let availability: 'known' | 'unknown' = 'unknown' + let sequence = 0 + let accepted = 0 + const observe = (request: number, status: typeof availability) => { + if (request >= accepted) { + accepted = request + availability = status + } + } + const list = async () => { + const request = ++sequence + try { + const credentials = await source.list() + observe(request, 'known') + return credentials + } + catch (error) { + observe(request, 'unknown') + throw error + } + } return { - list: () => source.list(), + get availability() { return availability }, + list, async listOrEmpty() { try { - return await source.list() + return await list() } catch (error) { if (isCredentialStoreUnavailable(error)) diff --git a/apps/buddy/service/src/providers/ProviderDependents.ts b/apps/buddy/service/src/providers/ProviderDependents.ts new file mode 100644 index 00000000..2fcde88a --- /dev/null +++ b/apps/buddy/service/src/providers/ProviderDependents.ts @@ -0,0 +1,170 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { SessionResourceReconciler } from '../agent/resources/SessionResourceReconciler' +import type { BuddySessionRegistry, DisposableBuddySession } from '../agent/sessions/BuddySessionRegistry' +import type { AutomationChangeCoordinator } from '../automations/AutomationChangeCoordinator' +import type { ProviderService } from './ProviderService' +import type { ProviderCommit } from './ProviderState' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' + +interface ProviderDependentsOptions { + source: Pick & Partial> + sessions: Pick, 'snapshot' | 'onDidChange'> + resources: Pick, 'reconcileInvalidation'> + automations: Pick + record: ApplicationDiagnosticReporter +} +export interface ProviderDependentsStatus { + readonly status: 'ready' | 'pending' | 'degraded' | 'stopped' + readonly pending: number + readonly revision: number +} + +export class ProviderDependents { + readonly #options: ProviderDependentsOptions + readonly #subscriptions: EventSubscription[] + readonly #pending = new Set>() + readonly #affected = new Set() + readonly #changes = new Emitter(() => console.error('PROVIDER_DEPENDENTS_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #failed = false + #stopped = false + #revision = 0 + + constructor(options: ProviderDependentsOptions) { + this.#options = { ...options, record: safeDiagnosticReporter(options.record) } + this.#subscriptions = [ + options.sessions.onDidChange((event) => { + if (this.#affected.has(event.session.id)) { + if (event.session.status === 'disposed' && event.session.cleanup !== 'failed') + this.#affected.delete(event.session.id) + this.#publish() + } + }), + options.source.onDidCommit(event => this.#committed(event)), + options.source.onDidChangeCredential((event) => { + if (event.kind !== 'observation' || event.current.presence === 'unknown') + return + const { providerId, presence, revision } = event.current + if (presence === 'absent') + this.#block(providerId) + this.#invalidate(`credential:${providerId}`, String(revision), model => model.providerId === providerId) + }), + ] + } + + get snapshot(): ProviderDependentsStatus { + const affected = this.#options.sessions.snapshot().filter(session => this.#affected.has(session.id)) + const pending = this.#pending.size + affected.filter(session => session.invalidationPending || session.cleanup === 'pending').length + const failed = this.#failed || affected.some(session => session.cleanup === 'failed') + return Object.freeze({ status: this.#stopped ? 'stopped' : failed ? 'degraded' : pending ? 'pending' : 'ready', pending, revision: this.#revision }) + } + + async whenIdle(): Promise { + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + async dispose(): Promise { + try { + if (this.#options.source.quiesce) + await this.#options.source.quiesce() + else await this.#options.source.whenIdle?.() + } + finally { + this.#stopped = true + for (const subscription of this.#subscriptions) subscription.dispose() + await this.whenIdle() + this.#publish() + this.#changes.dispose() + } + } + + reconcile(): void { + if (this.#stopped) + return + this.#failed = false + const snapshot = this.#options.source.snapshot + for (const provider of snapshot.providers) { + if (!provider.enabled) + this.#block(provider.id) + } + for (const model of snapshot.models) { + if (!model.enabled || !model.available) + this.#block(model.providerId, model.id) + } + this.#invalidate('reconcile', String(snapshot.revision), () => true) + } + + #committed(event: ProviderCommit): void { + if (this.#stopped) + return + for (const provider of event.providers) { + if (!provider.enabled) + this.#block(provider.providerId) + } + for (const model of event.models) { + if (!model.current?.enabled || !model.current.available) + this.#block(model.providerId, model.modelId) + } + const providers = new Set(event.providers.filter(provider => provider.executionChanged).map(provider => provider.providerId)) + const models = new Set(event.models.filter(model => model.facets.some(facet => facet === 'execution' || facet === 'availability')).map(model => JSON.stringify([model.providerId, model.modelId]))) + if (providers.size || models.size) + this.#invalidate('catalog', String(event.revision), model => providers.has(model.providerId) || models.has(JSON.stringify([model.providerId, model.modelId]))) + } + + #block(providerId: string, modelId?: string): void { + for (let attempt = 0; attempt < 3; attempt++) { + try { + this.#options.automations.blockPinnedModel(providerId, modelId) + return + } + catch { + if (attempt === 2) + this.#fail() + } + } + } + + #invalidate(scope: string, revision: string, matches: (model: { providerId: string, modelId: string }) => boolean): void { + if (this.#stopped) + return + const sessionIds = this.#options.sessions.snapshot().filter(session => this.#affected.has(session.id) || (scope !== 'reconcile' && (session.model ? matches(session.model) : session.status === 'pending'))).map(session => session.id) + if (!sessionIds.length) { + this.#publish() + return + } + for (const id of sessionIds) this.#affected.add(id) + const work = Promise.resolve().then(async () => { + for (let attempt = 0; ; attempt++) { + try { + return await this.#options.resources.reconcileInvalidation({ source: 'provider', scope, revision, sessionIds, matches: () => true, retry: scope === 'reconcile' || attempt > 0 }) + } + catch (error) { + if (attempt >= 2) + throw error + } + } + }).then((result) => { + if (result.degraded) + this.#fail() + }, () => this.#fail()).finally(() => { + this.#pending.delete(work) + this.#publish() + }) + this.#pending.add(work) + this.#publish() + } + + #fail(): void { + this.#failed = true + this.#options.record({ event: 'provider.dependencies.degraded', level: 'warn' }) + this.#publish() + } + + #publish(): void { + this.#revision++ + this.#changes.fire(this.snapshot) + } +} diff --git a/apps/buddy/service/src/providers/ProviderExecutionModelResolver.ts b/apps/buddy/service/src/providers/ProviderExecutionModelResolver.ts index 883e2518..b2d398a2 100644 --- a/apps/buddy/service/src/providers/ProviderExecutionModelResolver.ts +++ b/apps/buddy/service/src/providers/ProviderExecutionModelResolver.ts @@ -76,11 +76,13 @@ export class ProviderExecutionModelResolver { )) { throw new ProviderUnavailableError() } - const model = this.resolve(input) - const credentials = await this.#credentialStatus.listOrEmpty() + input = { ...input } + const credentials = await this.#credentialStatus.list() if (!credentials.some(credential => credential.providerId === input.providerId)) throw new ProviderAuthenticationRequiredError() - return model + if (!this.#states.findByProviderId(input.providerId)?.enabled || !this.#modelCatalog.isEnabledAvailable(input.providerId, input.modelId)) + throw new ProviderUnavailableError() + return this.resolve(input) } async resolveSession(input: ProviderExecutionModelInput): Promise { diff --git a/apps/buddy/service/src/providers/ProviderModelCatalog.ts b/apps/buddy/service/src/providers/ProviderModelCatalog.ts index 276c5ecb..264d1a62 100644 --- a/apps/buddy/service/src/providers/ProviderModelCatalog.ts +++ b/apps/buddy/service/src/providers/ProviderModelCatalog.ts @@ -248,7 +248,6 @@ export class ProviderModelCatalog { continue this.#models.upsert({ ...current, available: false, updatedAt: now }) } - this.registerCustomProvider(provider) } reconcileSyncedModelMetadata(provider: ProviderConfigRecord): boolean { @@ -312,7 +311,6 @@ export class ProviderModelCatalog { : current?.sourceRevision ?? now, updatedAt: now, }) - this.registerCustomProvider(provider) return this.#toBuddyModel(next) } @@ -342,7 +340,6 @@ export class ProviderModelCatalog { : current.sourceRevision, updatedAt: now, }) - this.registerCustomProvider(provider) return this.#toBuddyModel(next) } @@ -423,9 +420,12 @@ export class ProviderModelCatalog { if (!current || current.available) throw new ProviderUnavailableError() this.#models.remove(providerId, modelId) - const provider = this.#configs.findById(providerId) - if (provider) - this.registerCustomProvider(provider) + } + + executionSnapshot(providerId: string, modelId: string) { + const state = this.#requireModelState(providerId, modelId) + const { name: _name, ...execution } = this.#effectiveModel(state, this.#sourceModel(state)) + return execution } resolve(providerId: string, modelId: string): Model { diff --git a/apps/buddy/service/src/providers/ProviderModelDiscovery.ts b/apps/buddy/service/src/providers/ProviderModelDiscovery.ts index 2137c785..30151aee 100644 --- a/apps/buddy/service/src/providers/ProviderModelDiscovery.ts +++ b/apps/buddy/service/src/providers/ProviderModelDiscovery.ts @@ -17,6 +17,7 @@ export interface ProviderModelDefinition { } export interface ProviderModelDiscoveryInput { + readonly signal?: AbortSignal readonly api: string readonly baseUrl: string readonly providerId: string @@ -81,7 +82,7 @@ export class OpenAiCompatibleModelDiscovery implements ProviderModelDiscovery { const response = await this.#request(new URL('models', baseUrl), { headers, redirect: 'error', - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.any([AbortSignal.timeout(15_000), ...(input.signal ? [input.signal] : [])]), }) evidence.responseObserved = true evidence.responseMs = Math.round(performance.now() - startedAt) diff --git a/apps/buddy/service/src/providers/ProviderModelSnapshotService.ts b/apps/buddy/service/src/providers/ProviderModelSnapshotService.ts index f9e61ec5..2c3d6a1d 100644 --- a/apps/buddy/service/src/providers/ProviderModelSnapshotService.ts +++ b/apps/buddy/service/src/providers/ProviderModelSnapshotService.ts @@ -1,9 +1,11 @@ import type { ModelMetadataCatalog } from './ModelsDevCatalog' import { Buffer } from 'node:buffer' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import bundledSnapshotPath from './data/models-dev.json?asset' import { isRecord, ModelsDevCatalog } from './ModelsDevCatalog' @@ -33,7 +35,24 @@ interface ProviderModelSnapshotServiceOptions { const MAX_SNAPSHOT_BYTES = 20 * 1024 * 1024 +export interface ModelMetadataChange { + readonly revision: number + readonly catalogRevision: number + readonly operationId: string + readonly kind: 'refresh-started' | 'disk-committed' | 'accepted' | 'refresh-completed' | 'refresh-failed' | 'refresh-cancelled' | 'cleanup-failed' + readonly source: 'builtin' | 'remote' + readonly modelCount: number + readonly providerCount: number +} + export class ProviderModelSnapshotService implements ModelMetadataCatalog { + readonly #changes = new Emitter(() => console.error('MODEL_METADATA_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #catalogRevision = 0 + #fingerprint: string + #stopped = false + readonly #controller = new AbortController() readonly #snapshotPath: string | undefined readonly #fetch: typeof globalThis.fetch readonly #now: () => Date @@ -54,6 +73,7 @@ export class ProviderModelSnapshotService implements ModelMetadataCatalog { this.#generatedAt = snapshot.updatedAt this.#updatedAt = snapshot.updatedAt this.#catalog = new ModelsDevCatalog(snapshot.data) + this.#fingerprint = catalogFingerprint(this.#catalog) } async initialize(): Promise { @@ -65,10 +85,8 @@ export class ProviderModelSnapshotService implements ModelMetadataCatalog { const snapshot = parseSnapshot(JSON.parse(await readFile(this.#snapshotPath, 'utf8'))) const catalog = new ModelsDevCatalog(snapshot.data) if (Date.parse(snapshot.updatedAt) >= Date.parse(this.#generatedAt)) { - this.#updatedAt = snapshot.updatedAt - this.#catalog = catalog - this.#source = 'remote' - this.#checkedAt = snapshot.updatedAt + if (!this.#stopped) + this.#accept(catalog, snapshot.updatedAt, randomUUID()) } } catch { @@ -98,17 +116,29 @@ export class ProviderModelSnapshotService implements ModelMetadataCatalog { } refresh(): Promise { - this.#refreshRequest ??= this.#runRefresh().finally(() => { + if (this.#stopped) + return Promise.reject(new Error('MODEL_METADATA_STOPPED')) + if (this.#refreshRequest) + return this.#refreshRequest + const pending = Promise.withResolvers() + this.#refreshRequest = pending.promise + void this.#runRefresh().then((result) => { + this.#refreshRequest = null + pending.resolve(result) + }, (error: unknown) => { this.#refreshRequest = null + pending.reject(error) }) - return this.#refreshRequest + return pending.promise } async #runRefresh(): Promise { this.#lastAttemptAt = this.#now().toISOString() + const operationId = randomUUID() + this.#publish('refresh-started', operationId) try { const response = await this.#fetch('https://models.dev/api.json', { - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.any([this.#controller.signal, AbortSignal.timeout(15_000)]), redirect: 'error', }) if (!response.ok || !response.body) @@ -138,23 +168,62 @@ export class ProviderModelSnapshotService implements ModelMetadataCatalog { const temporaryPath = `${this.#snapshotPath}.${randomUUID()}.tmp` try { await writeFile(temporaryPath, JSON.stringify(snapshot), { mode: 0o600, flag: 'wx' }) + this.#controller.signal.throwIfAborted() await rename(temporaryPath, this.#snapshotPath) + this.#publish('disk-committed', operationId, catalog) + this.#errorCount = 0 + this.#accept(catalog, snapshot.updatedAt, operationId) } finally { - await rm(temporaryPath, { force: true }) + try { + await rm(temporaryPath, { force: true }) + } + catch { this.#publish('cleanup-failed', operationId) } } } - this.#updatedAt = snapshot.updatedAt - this.#catalog = catalog - this.#source = 'remote' - this.#checkedAt = snapshot.updatedAt - this.#errorCount = 0 + else { + this.#controller.signal.throwIfAborted() + this.#errorCount = 0 + this.#accept(catalog, snapshot.updatedAt, operationId) + } + this.#publish('refresh-completed', operationId) } catch { this.#errorCount = 1 + this.#publish(this.#stopped ? 'refresh-cancelled' : 'refresh-failed', operationId) } return this.getStatus() } + + async dispose(): Promise { + this.#stopped = true + this.#controller.abort() + await this.#refreshRequest + this.#changes.dispose() + } + + #accept(catalog: ModelsDevCatalog, updatedAt: string, operationId: string): void { + const fingerprint = catalogFingerprint(catalog) + const changed = fingerprint !== this.#fingerprint + const sourceChanged = this.#source !== 'remote' + this.#fingerprint = fingerprint + this.#catalog = catalog + this.#source = 'remote' + this.#updatedAt = updatedAt + this.#checkedAt = updatedAt + if (changed) + this.#catalogRevision++ + if (changed || sourceChanged) + this.#publish('accepted', operationId) + } + + #publish(kind: ModelMetadataChange['kind'], operationId: string, committed?: ModelsDevCatalog): void { + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, catalogRevision: this.#catalogRevision, operationId, kind, source: committed ? 'remote' : this.#source, modelCount: (committed ?? this).getModels().length, providerCount: (committed ?? this).getProviders().length })) + } +} + +function catalogFingerprint(catalog: ModelsDevCatalog): string { + return createHash('sha256').update(JSON.stringify({ providers: [...catalog.getProviders()].sort((a, b) => a.id.localeCompare(b.id)), models: [...catalog.getModels()].sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id)) })).digest('hex') } function parseSnapshot(value: unknown): Snapshot { diff --git a/apps/buddy/service/src/providers/ProviderService.ts b/apps/buddy/service/src/providers/ProviderService.ts index a2da6ab4..dd1a51bb 100644 --- a/apps/buddy/service/src/providers/ProviderService.ts +++ b/apps/buddy/service/src/providers/ProviderService.ts @@ -3,6 +3,7 @@ import type { Provider, } from '@earendil-works/pi-ai' import type { ModelRuntime } from '@earendil-works/pi-coding-agent' +import type { Event } from '../../../shared/events/Emitter' import type { ModelCapabilityOverrides } from '../../../shared/providers/providerCapabilities' import type { ModelCatalogReference } from '../../../shared/providers/providerCatalog' import type { ProviderRequestHeader } from '../../../shared/providers/providerHeaders' @@ -16,16 +17,19 @@ import type { import type { ProviderRepository } from '../storage/providerRepository' import type { ProviderStateRepository } from '../storage/providerStateRepository' import type { AuthInteractionService } from './AuthInteractionService' +import type { CredentialChange, HostCredentialStore } from './HostCredentialStore' import type { ProviderCredentialStatus } from './ProviderCredentialStatus' import type { ProviderModelCatalogRuntime, } from './ProviderModelCatalog' import type { ProviderModelDiscovery } from './ProviderModelDiscovery' -import type { ProviderModelSnapshotStatus } from './ProviderModelSnapshotService' +import type { ModelMetadataChange, ProviderModelSnapshotStatus } from './ProviderModelSnapshotService' import type { BuddyModel, BuddyProvider, ModelParametersOverride } from './providerSchemas' +import type { ProviderCommit } from './ProviderState' import { randomUUID } from 'node:crypto' import { getSupportedThinkingLevels } from '@earendil-works/pi-ai' import { builtinProviders } from '@earendil-works/pi-ai/providers/all' +import { Emitter } from '../../../shared/events/Emitter' import { providerRequestHeadersSchema } from '../../../shared/providers/providerHeaders' import { customProviderInputSchema, defaultModelSchema, providerDisplayNameSchema } from '../../../shared/providers/providerInput' import { createBuiltinProviderInstance } from './createBuiltinProviderInstance' @@ -43,6 +47,7 @@ import { ProviderModelCatalog } from './ProviderModelCatalog' import { ProviderModelSnapshotService } from './ProviderModelSnapshotService' import { ProviderRequestHeaders } from './ProviderRequestHeaders' import { buddyProviderSchema } from './providerSchemas' +import { ProviderState } from './ProviderState' export interface ProviderModelRuntime extends ProviderModelCatalogRuntime { getProvider: (providerId: string) => Provider | undefined @@ -55,13 +60,14 @@ export interface ProviderModelRuntime extends ProviderModelCatalogRuntime { getAvailable: ModelRuntime['getAvailable'] } -type ModelSnapshot = Pick +type ModelSnapshot = Pick & Partial> export interface ProviderServiceOptions { requestHeaders?: ProviderRequestHeaders createBuiltinSource?: (providerId: string) => Provider | undefined authInteractions: AuthInteractionService credentialStatus: ProviderCredentialStatus + credentials?: Pick getActiveRuns?: () => ReadonlyArray<{ model: string, provider: string }> modelDiscovery: ProviderModelDiscovery modelRuntime: ProviderModelRuntime @@ -78,6 +84,7 @@ export class ProviderService { readonly #createBuiltinSource: (providerId: string) => Provider | undefined readonly #authInteractions: AuthInteractionService readonly #configs: ProviderConfigRepository + readonly #credentials: ProviderServiceOptions['credentials'] readonly #credentialStatus: ProviderCredentialStatus readonly #defaultModel: DefaultModelRepository readonly #getActiveRuns: () => ReadonlyArray<{ model: string, provider: string }> @@ -87,6 +94,21 @@ export class ProviderService { readonly #modelSnapshot: ModelSnapshot readonly #states: ProviderStateRepository readonly executionModels: ProviderExecutionModelResolver + readonly #state: ProviderState + readonly #operations = new Emitter<{ readonly operationId: string, readonly operation: string, readonly stage: 'started' | 'completed' | 'failed' | 'cancelled' }>(() => console.error('PROVIDER_OPERATION_OBSERVER_FAILED')) + readonly onDidOperate = this.#operations.event + readonly #pending = new Set>() + readonly #shutdown = new AbortController() + #stopping = false + #quiescence: Promise | undefined + #disposal: Promise | undefined + readonly #applications = new Emitter<{ readonly providerId: string, readonly revision: number, readonly operationId: string, readonly stage: 'started' | 'applied' | 'failed' }>(() => console.error('PROVIDER_APPLICATION_OBSERVER_FAILED')) + readonly onDidApplyCatalog = this.#applications.event + readonly onDidChangeCredential: Event = (listener, options) => this.#credentials?.onDidChange(listener, options) ?? { dispose() {} } + readonly onDidChangeMetadata: Event = (listener, options) => this.#modelSnapshot.onDidChange?.(listener, options) ?? { dispose() {} } + readonly onDidCommit: Event = (listener, options) => this.#state.onDidCommit(listener, options) + + get snapshot() { return this.#state.snapshot } constructor(options: ProviderServiceOptions) { this.#requestHeaders = options.requestHeaders ?? new ProviderRequestHeaders(options.providers.states) @@ -99,6 +121,7 @@ export class ProviderService { this.#authInteractions = options.authInteractions this.#configs = options.providers.configs this.#credentialStatus = options.credentialStatus + this.#credentials = options.credentials this.#defaultModel = options.providers.defaultModel this.#getActiveRuns = options.getActiveRuns ?? (() => []) this.#modelDiscovery = options.modelDiscovery @@ -119,48 +142,55 @@ export class ProviderService { sessionRuntime: options.sessionRuntime, states: options.providers.states, }) + this.#state = new ProviderState(() => this.#capture()) } async initializeProviders(): Promise { - await this.#modelSnapshot.initialize() - const customProviderIds = new Set() - for (const provider of this.#configs.list()) { - customProviderIds.add(provider.id) - this.#ensureProviderState(provider.id, provider.enabled) - this.#modelCatalog.seedStoredCustomModels(provider) - this.#modelCatalog.reconcileSyncedModelMetadata(provider) - this.#modelCatalog.registerCustomProvider(provider) - } - const credentialProviderIds = new Set((await this.#credentialStatus.listOrEmpty()) - .map(credential => credential.providerId)) - for (const provider of this.#builtinTemplates.values()) { - if (customProviderIds.has(provider.id) - || (!this.#states.findByProviderId(provider.id) && !credentialProviderIds.has(provider.id))) { - continue + return this.#operate('initializeProviders', async () => { + await this.#modelSnapshot.initialize() + const customProviderIds = new Set() + for (const provider of this.#configs.list()) { + customProviderIds.add(provider.id) + this.#state.commit('initialize', () => { + this.#ensureProviderState(provider.id, provider.enabled) + this.#modelCatalog.seedStoredCustomModels(provider) + this.#modelCatalog.reconcileSyncedModelMetadata(provider) + }) + this.#applyCatalog(provider.id, () => this.#modelCatalog.registerCustomProvider(provider)) } - this.#ensureProviderState(provider.id, false) - if (!this.#builtins.findById(provider.id)) { - const now = new Date().toISOString() - this.#builtins.upsert({ - id: provider.id, - builtinProviderId: provider.id, - displayName: null, - createdAt: now, - updatedAt: now, + const credentialProviderIds = new Set((await this.#credentialStatus.listOrEmpty()) + .map(credential => credential.providerId)) + for (const provider of this.#builtinTemplates.values()) { + if (customProviderIds.has(provider.id) + || (!this.#states.findByProviderId(provider.id) && !credentialProviderIds.has(provider.id))) { + continue + } + this.#state.commit('initialize', () => { + this.#ensureProviderState(provider.id, false) + if (!this.#builtins.findById(provider.id)) { + const now = new Date().toISOString() + this.#builtins.upsert({ + id: provider.id, + builtinProviderId: provider.id, + displayName: null, + createdAt: now, + updatedAt: now, + }) + } }) } - } - const instances = this.#builtins.list() - for (const instance of instances) { - this.#ensureProviderState(instance.id, false) - this.#registerBuiltinInstance(instance) - } - if (instances.length) - await this.#modelRuntime.refresh({ allowNetwork: false, providers: instances.map(instance => instance.id) }) - for (const instance of instances) { - if (this.#modelRuntime.getProvider(instance.id)) - await this.#reconcileBuiltinModels(instance, credentialProviderIds.has(instance.id)) - } + const instances = this.#builtins.list() + for (const instance of instances) { + this.#state.commit('initialize', () => this.#ensureProviderState(instance.id, false)) + this.#registerBuiltinInstance(instance) + } + if (instances.length) + await this.#modelRuntime.refresh({ signal: this.#shutdown.signal, allowNetwork: false, providers: instances.map(instance => instance.id) }) + for (const instance of instances) { + if (this.#modelRuntime.getProvider(instance.id)) + await this.#reconcileBuiltinModels(instance, credentialProviderIds.has(instance.id)) + } + }) } listBuiltinPresets() { @@ -203,7 +233,7 @@ export class ProviderService { canSyncModels: Boolean(provider && syncUnavailableReason === null), authTypes: provider ? providerAuthTypes(provider) : custom ? ['api_key'] : [], storedCredentialType, - status: !provider ? 'unavailable' : storedCredentialType ? 'available' : 'authentication_required', + status: !provider || this.#credentialStatus.availability === 'unknown' ? 'unavailable' : storedCredentialType ? 'available' : 'authentication_required', custom: Boolean(custom), activeRunCount: this.#activeRunsForProvider(id).length, added: true, @@ -226,85 +256,107 @@ export class ProviderService { } async refreshModelSnapshot(): Promise { - await this.#modelSnapshot.refresh() - for (const instance of this.#builtins.list()) { - if (this.#modelRuntime.getProvider(instance.id)) - this.#modelCatalog.reconcileBuiltinModels(instance.id, instance.builtinProviderId, { metadataOnly: true }) - } - for (const provider of this.#configs.list()) { - if (this.#modelCatalog.reconcileSyncedModelMetadata(provider)) - this.#modelCatalog.registerCustomProvider(provider) - } - return this.#modelSnapshot.getStatus() + return this.#operate('refreshModelSnapshot', async () => { + await this.#modelSnapshot.refresh() + for (const instance of this.#builtins.list()) { + if (this.#modelRuntime.getProvider(instance.id)) + this.#state.commit('metadata', () => this.#modelCatalog.reconcileBuiltinModels(instance.id, instance.builtinProviderId, { metadataOnly: true })) + } + for (const provider of this.#configs.list()) { + if (this.#state.commit('metadata', () => this.#modelCatalog.reconcileSyncedModelMetadata(provider))) + this.#applyCatalog(provider.id, () => this.#modelCatalog.registerCustomProvider(provider)) + } + return this.#modelSnapshot.getStatus() + }) } async addProvider(providerId: string): Promise { - const template = this.#builtinTemplates.get(providerId) - if (!template) - throw new ProviderUnavailableError() - const existingNames = new Set((await this.listProviders()).map(provider => provider.displayName)) - let displayName = template.name - for (let index = 2; existingNames.has(displayName); index += 1) - displayName = `${template.name} ${index}` - const now = new Date().toISOString() - const instance: BuiltinProviderConfigRecord = { - id: `builtin-${randomUUID()}`, - builtinProviderId: providerId, - displayName, - createdAt: now, - updatedAt: now, - } - if (!this.#registerBuiltinInstance(instance)) - throw new ProviderUnavailableError() - this.#builtins.upsert(instance) - this.#ensureProviderState(instance.id, false) - await this.#modelRuntime.refresh({ allowNetwork: false, providers: [instance.id] }) - await this.#reconcileBuiltinModels(instance, false) - return this.#requireListedProvider(instance.id) + return this.#operate('addProvider', async () => { + const template = this.#builtinTemplates.get(providerId) + if (!template) + throw new ProviderUnavailableError() + const existingNames = new Set((await this.listProviders()).map(provider => provider.displayName)) + let displayName = template.name + for (let index = 2; existingNames.has(displayName); index += 1) + displayName = `${template.name} ${index}` + const now = new Date().toISOString() + const instance: BuiltinProviderConfigRecord = { + id: `builtin-${randomUUID()}`, + builtinProviderId: providerId, + displayName, + createdAt: now, + updatedAt: now, + } + const runtimeProvider = this.#createBuiltinInstance(instance) + if (!runtimeProvider) + throw new ProviderUnavailableError() + this.#state.commit('configuration', () => { + this.#builtins.upsert(instance) + this.#ensureProviderState(instance.id, false) + }) + this.#applyCatalog(instance.id, () => this.#modelRuntime.registerNativeProvider(runtimeProvider)) + await this.#modelRuntime.refresh({ signal: this.#shutdown.signal, allowNetwork: false, providers: [instance.id] }) + await this.#reconcileBuiltinModels(instance, false) + return this.#requireListedProvider(instance.id) + }) } async renameProvider(providerId: string, displayName: string, requestHeaders?: readonly ProviderRequestHeader[]): Promise { - if (requestHeaders !== undefined) { - this.#assertProviderIdle(providerId) - if (!providerRequestHeadersSchema.safeParse(requestHeaders).success) + return this.#operate('renameProvider', async () => { + if (requestHeaders !== undefined) { + this.#assertProviderIdle(providerId) + if (!providerRequestHeadersSchema.safeParse(requestHeaders).success) + throw new ProviderValidationError() + } + const parsed = providerDisplayNameSchema.safeParse(displayName) + if (!parsed.success) throw new ProviderValidationError() - } - const parsed = providerDisplayNameSchema.safeParse(displayName) - if (!parsed.success) - throw new ProviderValidationError() - const builtin = this.#builtins.findById(providerId) - const custom = this.#configs.findById(providerId) - const updatedAt = new Date().toISOString() - if (builtin) - this.#builtins.upsert({ ...builtin, displayName: parsed.data, updatedAt }) - else if (custom) - this.#configs.upsert({ ...custom, displayName: parsed.data, updatedAt }) - else - throw new ProviderUnavailableError() - if (requestHeaders !== undefined) - this.#requestHeaders.save(providerId, requestHeaders) - return this.#requireListedProvider(providerId) + const builtin = this.#builtins.findById(providerId) + const custom = this.#configs.findById(providerId) + const updatedAt = new Date().toISOString() + this.#state.commit('configuration', () => { + if (builtin) + this.#builtins.upsert({ ...builtin, displayName: parsed.data, updatedAt }) + else if (custom) + this.#configs.upsert({ ...custom, displayName: parsed.data, updatedAt }) + else + throw new ProviderUnavailableError() + if (requestHeaders !== undefined) + this.#requestHeaders.save(providerId, requestHeaders) + }) + return this.#requireListedProvider(providerId) + }) } async upsertManualModel(providerId: string, input: ProviderModelInput): Promise { - const custom = this.#configs.findById(providerId) - if (!custom) - throw new ProviderValidationError() - this.#assertProviderIdle(providerId) - return this.#modelCatalog.upsertManualModel(custom, input) + return this.#operate('upsertManualModel', async () => { + const custom = this.#configs.findById(providerId) + if (!custom) + throw new ProviderValidationError() + this.#assertProviderIdle(providerId) + const model = this.#state.commit('configuration', () => this.#modelCatalog.upsertManualModel(custom, input)) + this.#applyCatalog(providerId, () => this.#modelCatalog.registerCustomProvider(custom)) + return model + }) } async setModelCatalogSource(providerId: string, modelId: string, source: ModelCatalogReference | null): Promise { - const provider = this.#configs.findById(providerId) - if (!provider) - throw new ProviderValidationError() - this.#assertProviderIdle(providerId) - return this.#modelCatalog.setCatalogSource(provider, modelId, source) + return this.#operate('setModelCatalogSource', async () => { + const provider = this.#configs.findById(providerId) + if (!provider) + throw new ProviderValidationError() + this.#assertProviderIdle(providerId) + const model = this.#state.commit('configuration', () => this.#modelCatalog.setCatalogSource(provider, modelId, source)) + this.#applyCatalog(providerId, () => this.#modelCatalog.registerCustomProvider(provider)) + return model + }) } async setModelCapabilities(providerId: string, modelId: string, capabilities: ModelCapabilityOverrides | null): Promise { - this.#assertProviderIdle(providerId) - return this.#modelCatalog.setCapabilitiesOverride(providerId, modelId, capabilities) + return this.#operate('setModelCapabilities', async () => { + this.#assertProviderIdle(providerId) + return this.#state.commit('configuration', () => this.#modelCatalog.setCapabilitiesOverride(providerId, modelId, capabilities)) + }) } async setModelParametersOverride( @@ -312,54 +364,72 @@ export class ProviderService { modelId: string, input: ModelParametersOverride, ): Promise { - return this.#modelCatalog.setParametersOverride(providerId, modelId, input) + return this.#operate('setModelParametersOverride', async () => { + return this.#state.commit('configuration', () => this.#modelCatalog.setParametersOverride(providerId, modelId, input)) + }) } async acknowledgeModelSourceUpdate(providerId: string, modelId: string): Promise { - return this.#modelCatalog.acknowledgeSourceUpdate(providerId, modelId) + return this.#operate('acknowledgeModelSourceUpdate', async () => { + return this.#state.commit('configuration', () => this.#modelCatalog.acknowledgeSourceUpdate(providerId, modelId)) + }) } async restoreModelSourceParameters(providerId: string, modelId: string): Promise { - return this.#modelCatalog.restoreSourceParameters(providerId, modelId) + return this.#operate('restoreModelSourceParameters', async () => { + return this.#state.commit('configuration', () => this.#modelCatalog.restoreSourceParameters(providerId, modelId)) + }) } async setModelEnabled(providerId: string, modelId: string, enabled: boolean): Promise { - this.#modelCatalog.assertCanSetEnabled(providerId, modelId, enabled) - if (!enabled) - this.#assertModelIdle(providerId, modelId) - const next = this.#modelCatalog.setEnabled(providerId, modelId, enabled) - if (!enabled) - this.#clearDefaultIfMatches(providerId, modelId) - return next + return this.#operate('setModelEnabled', async () => { + this.#modelCatalog.assertCanSetEnabled(providerId, modelId, enabled) + if (!enabled) + this.#assertModelIdle(providerId, modelId) + return this.#state.commit('configuration', () => { + const next = this.#modelCatalog.setEnabled(providerId, modelId, enabled) + if (!enabled) + this.#clearDefaultIfMatches(providerId, modelId) + return next + }) + }) } async removeModel(providerId: string, modelId: string): Promise { - this.#assertModelIdle(providerId, modelId) - this.#modelCatalog.removeUnavailableModel(providerId, modelId) - this.#clearDefaultIfMatches(providerId, modelId) + return this.#operate('removeModel', async () => { + this.#assertModelIdle(providerId, modelId) + this.#state.commit('configuration', () => { + this.#modelCatalog.removeUnavailableModel(providerId, modelId) + this.#clearDefaultIfMatches(providerId, modelId) + }) + const provider = this.#configs.findById(providerId) + if (provider) + this.#applyCatalog(providerId, () => this.#modelCatalog.registerCustomProvider(provider)) + }) } async setProviderEnabled(providerId: string, enabled: boolean): Promise { - const current = this.#states.findByProviderId(providerId) - if (!current) - throw new ProviderUnavailableError() - if (!enabled) - this.#assertProviderIdle(providerId) - if (enabled) { - const credentials = await this.#credentialStatus.list() - if (!credentials.some(credential => credential.providerId === providerId)) - throw new ProviderAuthenticationRequiredError() - if (!this.#modelCatalog.hasEnabledAvailableModel(providerId)) + return this.#operate('setProviderEnabled', async () => { + const current = this.#states.findByProviderId(providerId) + if (!current) throw new ProviderUnavailableError() - } - this.#states.upsert({ - ...current, - enabled, - updatedAt: new Date().toISOString(), + if (!enabled) + this.#assertProviderIdle(providerId) + if (enabled) { + const credentials = await this.#credentialStatus.list() + if (!credentials.some(credential => credential.providerId === providerId)) + throw new ProviderAuthenticationRequiredError() + if (!this.#modelCatalog.hasEnabledAvailableModel(providerId)) + throw new ProviderUnavailableError() + } + this.#assertProviderCurrent(providerId, current) + this.#state.commit('configuration', () => { + this.#states.upsert({ ...current, enabled, updatedAt: new Date().toISOString() }) + if (!enabled) + this.#clearDefaultForProvider(providerId) + }) + return this.#requireListedProvider(providerId) }) - if (!enabled) - this.#clearDefaultForProvider(providerId) - return this.#requireListedProvider(providerId) } getDefaultModel(): Promise { @@ -374,80 +444,98 @@ export class ProviderService { } async setDefaultModel(value: BuddyDefaultModel | null): Promise { - if (!value) { - this.#defaultModel.clear() - return null - } - const parsed = defaultModelSchema.parse(value) - const resolvedModel = await this.executionModels.resolveAvailable({ - contextWindow: null, - maxTokens: null, - modelId: parsed.modelId, - providerId: parsed.providerId, - }) - if ( - parsed.reasoning !== null - && !getSupportedThinkingLevels(resolvedModel).includes(parsed.reasoning) - ) { - throw new ProviderValidationError() - } - const stored = this.#defaultModel.set({ ...parsed, updatedAt: new Date().toISOString() }) - return defaultModelSchema.parse({ - modelId: stored.modelId, - providerId: stored.providerId, - reasoning: stored.reasoning, + return this.#operate('setDefaultModel', async () => { + if (!value) { + this.#state.commit('configuration', () => this.#defaultModel.clear()) + return null + } + const parsed = defaultModelSchema.parse(value) + const resolvedModel = await this.executionModels.resolveAvailable({ + contextWindow: null, + maxTokens: null, + modelId: parsed.modelId, + providerId: parsed.providerId, + }) + if ( + parsed.reasoning !== null + && !getSupportedThinkingLevels(resolvedModel).includes(parsed.reasoning) + ) { + throw new ProviderValidationError() + } + const stored = this.#state.commit('configuration', () => this.#defaultModel.set({ ...parsed, updatedAt: new Date().toISOString() })) + return defaultModelSchema.parse({ + modelId: stored.modelId, + providerId: stored.providerId, + reasoning: stored.reasoning, + }) }) } async syncModels(providerId: string): Promise { - if (!this.#states.findByProviderId(providerId)) - throw new ProviderUnavailableError() - const custom = this.#configs.findById(providerId) - const instance = this.#builtins.findById(providerId) - if (instance) { - this.#assertProviderIdle(providerId) - const provider = this.#modelRuntime.getProvider(providerId) - if (!provider) + return this.#operate('syncModels', async () => { + if (!this.#states.findByProviderId(providerId)) + throw new ProviderUnavailableError() + const custom = this.#configs.findById(providerId) + const instance = this.#builtins.findById(providerId) + if (instance) { + this.#assertProviderIdle(providerId) + const provider = this.#modelRuntime.getProvider(providerId) + if (!provider) + throw new ProviderModelSyncUnsupportedError() + if (!(await this.#credentialStatus.list()).some(credential => credential.providerId === providerId)) + throw new ProviderAuthenticationRequiredError() + const result = await this.#modelRuntime.refresh({ signal: this.#shutdown.signal, allowNetwork: true, force: true, providers: [providerId] }) + if (result.aborted || result.errors.size) + throw new ProviderModelSyncError() + if (JSON.stringify(this.#builtins.findById(providerId)) !== JSON.stringify(instance)) + throw new ProviderUnavailableError() + this.#assertProviderIdle(providerId) + await this.#reconcileBuiltinModels(instance, true) + return this.listModels(providerId) + } + if (!custom || !this.#modelDiscovery.supports(custom.api)) throw new ProviderModelSyncUnsupportedError() - if (!(await this.#credentialStatus.list()).some(credential => credential.providerId === providerId)) - throw new ProviderAuthenticationRequiredError() - const result = await this.#modelRuntime.refresh({ allowNetwork: true, force: true, providers: [providerId] }) - if (result.aborted || result.errors.size) - throw new ProviderModelSyncError() - await this.#reconcileBuiltinModels(instance, true) + this.#assertProviderIdle(providerId) + const definitions = await this.#modelDiscovery.discover({ + signal: this.#shutdown.signal, + api: custom.api, + baseUrl: custom.baseUrl, + providerId, + }) + if (JSON.stringify(this.#configs.findById(providerId)) !== JSON.stringify(custom)) + throw new ProviderUnavailableError() + this.#assertProviderIdle(providerId) + this.#state.commit('discovery', () => this.#modelCatalog.reconcileSyncedModels(custom, definitions)) + this.#applyCatalog(providerId, () => this.#modelCatalog.registerCustomProvider(custom)) return this.listModels(providerId) - } - if (!custom || !this.#modelDiscovery.supports(custom.api)) - throw new ProviderModelSyncUnsupportedError() - this.#assertProviderIdle(providerId) - const definitions = await this.#modelDiscovery.discover({ - api: custom.api, - baseUrl: custom.baseUrl, - providerId, }) - this.#modelCatalog.reconcileSyncedModels(custom, definitions) - return this.listModels(providerId) } async login(providerId: string, type: AuthType): Promise { - this.#assertProviderIdle(providerId) - const provider = this.#modelRuntime.getProvider(providerId) - if (!provider || !provider.auth[type === 'api_key' ? 'apiKey' : 'oauth']) - throw new ProviderUnavailableError() + return this.#operate('login', async () => { + this.#assertProviderIdle(providerId) + const provider = this.#modelRuntime.getProvider(providerId) + if (!provider || !provider.auth[type === 'api_key' ? 'apiKey' : 'oauth']) + throw new ProviderUnavailableError() - const handle = this.#authInteractions.beginLogin(providerId) - try { - await this.#modelRuntime.login(providerId, type, handle.interaction) - const instance = this.#builtins.findById(providerId) - if (instance) { - if (provider.refreshModels) - await this.#modelRuntime.refresh({ allowNetwork: true, providers: [providerId] }) - await this.#reconcileBuiltinModels(instance, true) + const handle = this.#authInteractions.beginLogin(providerId) + let outcome: 'completed' | 'failed' | 'cancelled' = 'failed' + try { + await this.#modelRuntime.login(providerId, type, handle.interaction) + const instance = this.#builtins.findById(providerId) + if (instance) { + if (provider.refreshModels) + await this.#modelRuntime.refresh({ signal: this.#shutdown.signal, allowNetwork: true, providers: [providerId] }) + await this.#reconcileBuiltinModels(instance, true) + } + outcome = 'completed' } - } - finally { - this.#authInteractions.completeLogin(handle.loginId) - } + finally { + if (handle.interaction.signal?.aborted) + outcome = 'cancelled' + this.#authInteractions.completeLogin(handle.loginId, outcome) + } + }) } respondToPrompt(challengeId: string, value: string): Promise { @@ -461,39 +549,52 @@ export class ProviderService { } async logout(providerId: string): Promise { - this.#assertProviderIdle(providerId) - await this.#modelRuntime.logout(providerId) + return this.#operate('logout', async () => { + this.#assertProviderIdle(providerId) + await this.#modelRuntime.logout(providerId) + }) } async clearCredential(providerId: string): Promise { - this.#assertProviderIdle(providerId) - await this.#modelRuntime.logout(providerId) + return this.#operate('clearCredential', async () => { + this.#assertProviderIdle(providerId) + await this.#modelRuntime.logout(providerId) + }) } async removeProvider(providerId: string): Promise { - this.#assertProviderIdle(providerId) - await this.#modelRuntime.logout(providerId) - this.#clearDefaultForProvider(providerId) - this.#modelCatalog.removeForProvider(providerId) - this.#states.remove(providerId) - const builtin = this.#builtins.findById(providerId) - if (builtin) { - this.#builtins.remove(providerId) - if (builtin.id !== builtin.builtinProviderId) - this.#modelRuntime.unregisterProvider(providerId) - } - if (this.#configs.findById(providerId)) { - this.#configs.remove(providerId) - this.#modelRuntime.unregisterProvider(providerId) - } + return this.#operate('removeProvider', async () => { + this.#assertProviderIdle(providerId) + await this.#modelRuntime.logout(providerId) + this.#assertProviderIdle(providerId) + this.#state.commit('configuration', () => { + this.#clearDefaultForProvider(providerId) + this.#modelCatalog.removeForProvider(providerId) + this.#states.remove(providerId) + const builtin = this.#builtins.findById(providerId) + if (builtin) { + this.#builtins.remove(providerId) + if (builtin.id !== builtin.builtinProviderId) + this.#modelRuntime.unregisterProvider(providerId) + } + if (this.#configs.findById(providerId)) { + this.#configs.remove(providerId) + this.#modelRuntime.unregisterProvider(providerId) + } + }) + }) } async upsertCustomProvider(input: CustomProviderInput): Promise { - return this.#saveCustomProvider(input, false) + return this.#operate('upsertCustomProvider', async () => { + return this.#saveCustomProvider(input, false) + }) } async createCustomProvider(input: CustomProviderInput): Promise { - return this.#saveCustomProvider(input, true) + return this.#operate('createCustomProvider', async () => { + return this.#saveCustomProvider(input, true) + }) } async #saveCustomProvider(input: CustomProviderInput, createOnly: boolean): Promise { @@ -508,24 +609,27 @@ export class ProviderService { this.#assertProviderIdle(parsed.data.id) const now = new Date().toISOString() const models = parsed.data.models.length > 0 ? parsed.data.models : existing?.models ?? [] - const record = this.#configs.upsert({ - id: parsed.data.id, - displayName: parsed.data.displayName, - description: parsed.data.description || null, - api: parsed.data.api, - baseUrl: parsed.data.baseUrl, - models, - credentialRef: parsed.data.id, - enabled: parsed.data.enabled, - createdAt: existing?.createdAt ?? now, - updatedAt: now, + const record = this.#state.commit('configuration', () => { + const record = this.#configs.upsert({ + id: parsed.data.id, + displayName: parsed.data.displayName, + description: parsed.data.description || null, + api: parsed.data.api, + baseUrl: parsed.data.baseUrl, + models, + credentialRef: parsed.data.id, + enabled: parsed.data.enabled, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + this.#ensureProviderState(record.id, parsed.data.enabled) + if (parsed.data.requestHeaders !== undefined) + this.#requestHeaders.save(record.id, parsed.data.requestHeaders) + this.#modelCatalog.seedStoredCustomModels(record) + this.#modelCatalog.reconcileSyncedModelMetadata(record) + return record }) - this.#ensureProviderState(record.id, parsed.data.enabled) - if (parsed.data.requestHeaders !== undefined) - this.#requestHeaders.save(record.id, parsed.data.requestHeaders) - this.#modelCatalog.seedStoredCustomModels(record) - this.#modelCatalog.reconcileSyncedModelMetadata(record) - this.#modelCatalog.registerCustomProvider(record) + this.#applyCatalog(record.id, () => this.#modelCatalog.registerCustomProvider(record)) const provider = (await this.listProviders()).find(provider => provider.id === record.id) if (!provider) throw new ProviderUnavailableError() @@ -544,29 +648,130 @@ export class ProviderService { } #registerBuiltinInstance(instance: BuiltinProviderConfigRecord): boolean { - const template = this.#builtinTemplates.get(instance.builtinProviderId) - if (!template) - return false - const source = this.#createBuiltinSource(instance.builtinProviderId) - if (!source) + const provider = this.#createBuiltinInstance(instance) + if (!provider) return false - this.#modelRuntime.registerNativeProvider(createBuiltinProviderInstance({ + this.#applyCatalog(instance.id, () => this.#modelRuntime.registerNativeProvider(provider)) + return true + } + + #createBuiltinInstance(instance: BuiltinProviderConfigRecord): Provider | null { + const template = this.#builtinTemplates.get(instance.builtinProviderId) + const source = template && this.#createBuiltinSource(instance.builtinProviderId) + if (!source || !template) + return null + return createBuiltinProviderInstance({ id: instance.id, name: instance.displayName ?? template.name, source, getCatalogModels: () => template.getModels(), - })) - return true + }) } async #reconcileBuiltinModels(instance: BuiltinProviderConfigRecord, authenticated: boolean): Promise { const models = authenticated ? await this.#modelRuntime.getAvailable(instance.id) : this.#modelRuntime.getModels(instance.id) - this.#modelCatalog.reconcileBuiltinModels(instance.id, instance.builtinProviderId, { + if (!this.#builtins.findById(instance.id)) + throw new ProviderUnavailableError() + this.#state.commit('discovery', () => this.#modelCatalog.reconcileBuiltinModels(instance.id, instance.builtinProviderId, { models, metadataOnly: !authenticated && this.#modelCatalog.hasModels(instance.id), + })) + } + + #capture() { + const builtin = new Map(this.#builtins.list().map(provider => [provider.id, provider])) + const custom = new Map(this.#configs.list().map(provider => [provider.id, provider])) + return { + providers: [...new Set([...builtin.keys(), ...custom.keys()])].sort().map(id => ({ + id, + enabled: this.#states.findByProviderId(id)?.enabled ?? false, + presentation: { name: custom.get(id)?.displayName ?? builtin.get(id)?.displayName, description: custom.get(id)?.description }, + execution: { api: custom.get(id)?.api, baseUrl: custom.get(id)?.baseUrl, builtin: builtin.get(id)?.builtinProviderId, headers: this.#requestHeaders.list(id) }, + })), + models: this.#modelCatalog.list().map(model => ({ model, execution: { ...this.#modelCatalog.executionSnapshot(model.providerId, model.id), serviceTiers: this.executionModels.getServiceTiers({ providerId: model.providerId, modelId: model.id, api: model.api }) } })), + defaultModel: (() => { + const value = this.#defaultModel.find() + return value ? { providerId: value.providerId, modelId: value.modelId, reasoning: value.reasoning } : null + })(), + } + } + + #assertProviderCurrent(providerId: string, current: import('../storage/providerStateRepository').ProviderStateRecord): void { + if (JSON.stringify(this.#states.findByProviderId(providerId)) !== JSON.stringify(current)) + throw new ProviderUnavailableError() + } + + #applyCatalog(providerId: string, apply: () => void): void { + const identity = { providerId, revision: this.#state.snapshot.revision, operationId: randomUUID() } + this.#applications.fire(Object.freeze({ ...identity, stage: 'started' })) + try { + this.#shutdown.signal.throwIfAborted() + apply() + this.#applications.fire(Object.freeze({ ...identity, stage: 'applied' })) + } + catch (error) { + this.#applications.fire(Object.freeze({ ...identity, stage: 'failed' })) + throw error + } + } + + async whenIdle(): Promise { + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + quiesce(): Promise { + if (this.#quiescence) + return this.#quiescence + this.#stopping = true + this.#shutdown.abort() + this.#authInteractions.dispose() + this.#quiescence = (async () => { + const failures: unknown[] = [] + try { + await this.#modelSnapshot.dispose?.() + } + catch (error) { failures.push(error) } + await this.whenIdle() + try { + await this.#credentials?.dispose() + } + catch (error) { failures.push(error) } + if (failures.length) + throw new AggregateError(failures, 'Provider shutdown failed') + })() + return this.#quiescence + } + + dispose(): Promise { + this.#disposal ??= this.quiesce().finally(() => { + this.#operations.dispose() + this.#applications.dispose() + this.#state.dispose() + }) + return this.#disposal + } + + #operate(operation: string, work: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new ProviderUnavailableError()) + const operationId = randomUUID() + const result = Promise.resolve().then(() => { + this.#shutdown.signal.throwIfAborted() + return work() + }).then((value) => { + this.#operations.fire(Object.freeze({ operationId, operation, stage: 'completed' })) + return value + }, (error: unknown) => { + this.#operations.fire(Object.freeze({ operationId, operation, stage: this.#stopping ? 'cancelled' : 'failed' })) + throw error }) + this.#pending.add(result) + void result.then(() => this.#pending.delete(result), () => this.#pending.delete(result)) + this.#operations.fire(Object.freeze({ operationId, operation, stage: 'started' })) + return result } #syncUnavailableReason( diff --git a/apps/buddy/service/src/providers/ProviderState.ts b/apps/buddy/service/src/providers/ProviderState.ts new file mode 100644 index 00000000..0fad468c --- /dev/null +++ b/apps/buddy/service/src/providers/ProviderState.ts @@ -0,0 +1,104 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { BuddyDefaultModel } from '../../../shared/providers/providerInput' +import type { BuddyModel } from './providerSchemas' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' + +export interface ProviderStateInput { + readonly providers: readonly { id: string, enabled: boolean, presentation: unknown, execution: unknown }[] + readonly models: readonly { model: EventSnapshot, execution: unknown }[] + readonly defaultModel: BuddyDefaultModel | null +} +export interface ProviderCommit { + readonly revision: number + readonly catalogRevision: number + readonly commitId: string + readonly reason: 'initialize' | 'configuration' | 'metadata' | 'discovery' + readonly providers: readonly { providerId: string, kind: 'added' | 'changed' | 'removed', executionChanged: boolean, enabled: boolean }[] + readonly models: readonly { providerId: string, modelId: string, facets: readonly ('execution' | 'availability' | 'presentation')[], current: EventSnapshot | null }[] + readonly defaultModelChanged: boolean + readonly defaultModel: BuddyDefaultModel | null +} +export class ProviderState { + readonly #changes = new Emitter(() => console.error('PROVIDER_OBSERVER_FAILED')) + readonly onDidCommit = this.#changes.event + readonly #read: () => ProviderStateInput + #current: ProviderStateInput + #revision = 0 + #catalogRevision = 0 + #depth = 0 + + constructor(read: () => ProviderStateInput) { + this.#read = read + this.#current = copyEventSnapshot(read()) + } + + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, catalogRevision: this.#catalogRevision, providers: this.#current.providers.map(({ id, enabled }) => ({ id, enabled })), models: this.#current.models.map(entry => entry.model), defaultModel: this.#current.defaultModel }) + } + + commit(reason: ProviderCommit['reason'], operation: () => T): T { + this.#depth++ + try { + return operation() + } + finally { + this.#depth-- + if (!this.#depth) + this.#publish(reason) + } + } + + dispose(): void { this.#changes.dispose() } + + #publish(reason: ProviderCommit['reason']): void { + const current = copyEventSnapshot(this.#read()) + const beforeProviders = new Map(this.#current.providers.map(provider => [provider.id, provider])) + const afterProviders = new Map(current.providers.map(provider => [provider.id, provider])) + const providers: ProviderCommit['providers'][number][] = [] + for (const id of new Set([...beforeProviders.keys(), ...afterProviders.keys()])) { + const before = beforeProviders.get(id) + const after = afterProviders.get(id) + if (equal(before, after)) + continue + providers.push({ providerId: id, kind: !before ? 'added' : !after ? 'removed' : 'changed', executionChanged: !before || !after || before.enabled !== after.enabled || !equal(before.execution, after.execution), enabled: after?.enabled ?? false }) + } + const key = (model: EventSnapshot) => JSON.stringify([model.providerId, model.id]) + const beforeModels = new Map(this.#current.models.map(entry => [key(entry.model), entry])) + const afterModels = new Map(current.models.map(entry => [key(entry.model), entry])) + const models: ProviderCommit['models'][number][] = [] + for (const id of new Set([...beforeModels.keys(), ...afterModels.keys()])) { + const before = beforeModels.get(id) + const after = afterModels.get(id) + if (equal(modelValue(before), modelValue(after))) + continue + const model = (after ?? before)!.model + const facets: Array<'execution' | 'availability' | 'presentation'> = [] + if (!before || !after || !equal(before.execution, after.execution)) + facets.push('execution') + if (!before || !after || before.model.enabled !== after.model.enabled || before.model.available !== after.model.available) + facets.push('availability') + if (!equal(before?.model, after?.model)) + facets.push('presentation') + models.push({ providerId: model.providerId, modelId: model.id, facets, current: after?.model ?? null }) + } + const defaultModelChanged = !equal(this.#current.defaultModel, current.defaultModel) + this.#current = current + if (!providers.length && !models.length && !defaultModelChanged) + return + if (models.length) + this.#catalogRevision++ + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, catalogRevision: this.#catalogRevision, commitId: randomUUID(), reason, providers, models, defaultModelChanged, defaultModel: current.defaultModel })) + } +} +function equal(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function modelValue(entry: ProviderStateInput['models'][number] | undefined) { + if (!entry) + return entry + const { lastSeenAt: _seen, ...model } = entry.model + return { model, execution: entry.execution } +} diff --git a/apps/buddy/service/src/providers/__tests__/AuthInteractionService.spec.ts b/apps/buddy/service/src/providers/__tests__/AuthInteractionService.spec.ts new file mode 100644 index 00000000..42ecaee9 --- /dev/null +++ b/apps/buddy/service/src/providers/__tests__/AuthInteractionService.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { AuthInteractionService } from '../AuthInteractionService' + +describe('authentication interaction lifetime', () => { + it('keeps a prompt response independent of passive observers and removes abort listeners', async () => { + const service = new AuthInteractionService() + const changes: string[] = [] + service.onDidChange(event => changes.push(event.kind)) + service.onDidChallenge(event => service.respondToPrompt(event.challengeId, 'fixture-response')) + const login = service.beginLogin('fixture') + const signal = new AbortController() + const response = login.interaction.prompt({ type: 'secret', message: 'Fixture', signal: signal.signal }) + await expect(response).resolves.toBe('fixture-response') + signal.abort() + service.completeLogin(login.loginId) + service.completeLogin(login.loginId) + expect(changes).toEqual(['started', 'challenge-opened', 'challenge-closed', 'ended']) + expect(service.snapshot.logins).toEqual([]) + expect(JSON.stringify(service.snapshot)).not.toContain('fixture-response') + service.dispose() + }) + + it('owns selection inputs and cancels outstanding prompts exactly once on disposal', async () => { + const service = new AuthInteractionService() + const login = service.beginLogin('fixture') + const options = [{ id: 'original', label: 'Original' }] + let challengeId = '' + service.onDidChallenge((event) => { + challengeId = event.challengeId + }) + const first = login.interaction.prompt({ type: 'select', message: 'Fixture', options }) + options[0]!.id = 'changed' + expect(() => service.respondToPrompt(challengeId, 'changed')).toThrow() + service.respondToPrompt(challengeId, 'original') + await expect(first).resolves.toBe('original') + const second = login.interaction.prompt({ type: 'text', message: 'Fixture' }) + const cancelled = expect(second).rejects.toMatchObject({ code: 'PROVIDER_LOGIN_CANCELLED' }) + service.dispose() + await cancelled + expect(login.interaction.signal?.aborted).toBe(true) + expect(service.snapshot.logins).toEqual([]) + }) +}) diff --git a/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts b/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts index 2080ee25..92ef2983 100644 --- a/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts @@ -33,12 +33,11 @@ describe('built-in provider instances', () => { return provider } runtime.registerNativeProvider(source()) - const authInteractions = new AuthInteractionService({ - notify: (_method, input) => { - const challenge = providerAuthChallengeSchema.parse(input) - if (challenge.type === 'secret') - authInteractions.respondToPrompt(challenge.challengeId, 'fixture-not-a-real-key') - }, + const authInteractions = new AuthInteractionService() + authInteractions.onDidChallenge((input) => { + const challenge = providerAuthChallengeSchema.parse(input) + if (challenge.type === 'secret') + authInteractions.respondToPrompt(challenge.challengeId, 'fixture-not-a-real-key') }) const service = new ProviderService({ authInteractions, @@ -124,7 +123,7 @@ describe('built-in provider instances', () => { const credentials = new InMemoryCredentialStore() const runtime = await ModelRuntime.create({ credentials, modelsPath: null, modelsStore: new InMemoryModelsStore(), refreshOnCreate: false }) const service = new ProviderService({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), credentialStatus: createProviderCredentialStatus(credentials), modelDiscovery: { supports: () => false, discover: async () => [] }, modelRuntime: runtime, @@ -166,7 +165,7 @@ describe('built-in provider instances', () => { } } }) let fast = true const service = new ProviderService({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), credentialStatus: createProviderCredentialStatus(credentials), modelDiscovery: { supports: () => false, discover: async () => [] }, modelRuntime: runtime, @@ -207,7 +206,7 @@ describe('built-in provider instances', () => { const runtime = await ModelRuntime.create({ credentials, modelsPath: null, modelsStore: new InMemoryModelsStore(), refreshOnCreate: false }) runtime.registerNativeProvider(sources.provider) const service = new ProviderService({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), createBuiltinSource: () => sources.provider, credentialStatus: createProviderCredentialStatus(credentials), modelDiscovery: { supports: () => false, discover: async () => [] }, diff --git a/apps/buddy/service/src/providers/__tests__/HostCredentialStore.spec.ts b/apps/buddy/service/src/providers/__tests__/HostCredentialStore.spec.ts index 6456f41c..de17d878 100644 --- a/apps/buddy/service/src/providers/__tests__/HostCredentialStore.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/HostCredentialStore.spec.ts @@ -5,6 +5,69 @@ import { describe, expect, it } from 'vitest' import { HostCredentialStore } from '../HostCredentialStore' describe('hostCredentialStore', () => { + it('drains an accepted read before closing observations and rejects fresh requests during stop', async () => { + const peer = new CredentialHostPeer({}) + const pending = Promise.withResolvers() + peer.request = () => pending.promise + const store = new HostCredentialStore(peer) + const observed: string[] = [] + store.onDidChange(event => observed.push(event.kind)) + const read = store.read('fixture') + const stopping = store.dispose() + await expect(store.list()).rejects.toMatchObject({ code: 'CREDENTIAL_STORE_UNAVAILABLE' }) + pending.resolve({ ok: true, value: null }) + await read + await stopping + expect(store.snapshot.providers).toMatchObject([{ providerId: 'fixture', presence: 'absent' }]) + expect(observed).toEqual(['observation']) + }) + + it('distinguishes unknown host state from absence and suppresses token-only changes', async () => { + const peer = new CredentialHostPeer({ fixture: { type: 'oauth', access: 'fixture-a', refresh: 'fixture-r', expires: 0 } }) + const store = new HostCredentialStore(peer) + const changes: import('../HostCredentialStore').CredentialChange[] = [] + store.onDidChange(event => changes.push(event)) + await store.list() + const before = changes.filter(event => event.kind === 'observation').length + await store.modify('fixture', async current => ({ ...current!, type: 'oauth', access: 'fixture-b', refresh: 'fixture-r', expires: 1 })) + expect(changes.filter(event => event.kind === 'observation')).toHaveLength(before) + expect(changes.at(-1)?.kind).toBe('write-confirmed') + const request = peer.request.bind(peer) + peer.request = async () => { + throw new Error('fixture unavailable') + } + await expect(store.list()).rejects.toThrow('fixture unavailable') + expect(store.snapshot.availability).toBe('unknown') + expect(store.snapshot.providers).toMatchObject([{ presence: 'present' }]) + peer.request = request + await store.delete('fixture') + expect(store.snapshot.providers).toMatchObject([{ presence: 'absent', type: null }]) + expect(JSON.stringify(changes)).not.toContain('fixture-b') + await store.dispose() + }) + + it('does not let an older read restore a deleted credential or an older list failure replace current health', async () => { + const peer = new CredentialHostPeer({ fixture: { type: 'api_key', key: 'fixture-key' } }) + const store = new HostCredentialStore(peer) + const request = peer.request.bind(peer) + const oldRead = Promise.withResolvers() + peer.request = (method, params) => method === 'host.credentials.read' ? oldRead.promise : request(method, params) + const reading = store.read('fixture') + await store.delete('fixture') + oldRead.resolve({ ok: true, value: { type: 'api_key', key: 'fixture-key' } }) + await reading + expect(store.snapshot.providers).toMatchObject([{ presence: 'absent' }]) + const oldList = Promise.withResolvers() + peer.request = () => oldList.promise + const listing = expect(store.list()).rejects.toThrow('fixture old list') + peer.request = request + await store.list() + oldList.reject(new Error('fixture old list')) + await listing + expect(store.snapshot.availability).toBe('known') + await store.dispose() + }) + it('serializes concurrent OAuth refreshes for the same provider', async () => { const peer = new CredentialHostPeer({ anthropic: { diff --git a/apps/buddy/service/src/providers/__tests__/ProviderDependents.spec.ts b/apps/buddy/service/src/providers/__tests__/ProviderDependents.spec.ts new file mode 100644 index 00000000..c3b8357c --- /dev/null +++ b/apps/buddy/service/src/providers/__tests__/ProviderDependents.spec.ts @@ -0,0 +1,90 @@ +import type { CredentialChange } from '../HostCredentialStore' +import type { ProviderCommit } from '../ProviderState' +import { describe, expect, it, vi } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' +import { BuddySessionRegistry } from '../../agent/sessions/BuddySessionRegistry' +import { ProviderDependents } from '../ProviderDependents' + +function identity() { + return { approvalPolicy: 'policy' as const, branchId: 'branch', canonicalRoot: '/workspace', conversationId: 'conversation', executionProfile: 'workspace_write' as const, grantRevision: 'grant', resourceRevision: 'resource', scratchRoot: '/scratch', sessionMode: 'interactive' as const, spaceId: null } +} +function source() { + const commits = new Emitter(() => {}) + const credentials = new Emitter(() => {}) + return { commits, onDidCommit: commits.event, onDidChangeCredential: credentials.event, snapshot: { revision: 0, catalogRevision: 0, models: [], providers: [], defaultModel: null } } +} +function commit(executionChanged: boolean): ProviderCommit { + return { revision: 1, catalogRevision: 0, commitId: 'commit', reason: 'configuration', providers: [{ providerId: 'fixture', kind: 'changed', executionChanged, enabled: true }], models: [], defaultModelChanged: false, defaultModel: null } +} + +describe('provider dependency reconciliation', () => { + it('keeps consumers connected while a source drains a final accepted commit', async () => { + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise, getModelUsage: () => { providerId: string, modelId: string } }>() + const events = source() + const gate = Promise.withResolvers() + let attempts = 0 + const sourceWithDrain = { ...events, quiesce: async () => { + await gate.promise + events.commits.fire(commit(true)) + } } + const consumer = new ProviderDependents({ source: sourceWithDrain, sessions, automations: { blockPinnedModel: () => [] }, record: () => {}, resources: { reconcileInvalidation: async (input) => { + if (++attempts === 1) + throw new Error('one failed invalidation read') + return sessions.invalidateMatching(input.matches, { sessionIds: input.sessionIds ?? [] }) + } } }) + await sessions.getOrCreate(identity(), null, async () => ({ piSessionFile: '/sessions/fixture', session: { shutdown: async () => {}, getModelUsage: () => ({ providerId: 'fixture', modelId: 'model' }) } })) + const stopping = consumer.dispose() + gate.resolve() + await stopping + expect(sessions.snapshot()).toEqual([]) + expect(consumer.snapshot.status).toBe('stopped') + await sessions.dispose() + }) + + it('leaves presentation alone and keeps applied state pending until the active session really releases', async () => { + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise, getModelUsage: () => { providerId: string, modelId: string } }>() + const events = source() + const consumer = new ProviderDependents({ source: events, sessions, automations: { blockPinnedModel: () => [] }, record: () => {}, resources: { reconcileInvalidation: input => sessions.invalidateMatching(input.matches, { sessionIds: input.sessionIds ?? [] }) } }) + await sessions.getOrCreate(identity(), null, async () => ({ piSessionFile: '/sessions/fixture', session: { shutdown: async () => {}, getModelUsage: () => ({ providerId: 'fixture', modelId: 'model' }) } })) + events.commits.fire(commit(false)) + await consumer.whenIdle() + expect(sessions.snapshot()).toHaveLength(1) + const gate = Promise.withResolvers() + const running = sessions.withConversationRun(identity(), 'run', undefined, () => gate.promise) + await vi.waitFor(() => expect(sessions.getActiveRun(identity())).not.toBeNull()) + events.commits.fire(commit(true)) + await consumer.whenIdle() + expect(consumer.snapshot.status).toBe('pending') + expect(sessions.snapshot()[0]?.invalidationPending).toBe(true) + gate.resolve() + await running + expect(consumer.snapshot.status).toBe('ready') + expect(sessions.snapshot()).toEqual([]) + await consumer.dispose() + await sessions.dispose() + }) + + it('does not invalidate a replacement session with a delayed request for its predecessor', async () => { + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise, getModelUsage: () => { providerId: string, modelId: string } }>() + const events = source() + const gate = Promise.withResolvers() + const consumer = new ProviderDependents({ source: events, sessions, automations: { blockPinnedModel: () => [] }, record: () => {}, resources: { reconcileInvalidation: async (input) => { + await gate.promise + return sessions.invalidateMatching(input.matches, { sessionIds: input.sessionIds ?? [] }) + } } }) + const create = () => sessions.getOrCreate(identity(), null, async () => ({ piSessionFile: '/sessions/fixture', session: { shutdown: async () => {}, getModelUsage: () => ({ providerId: 'fixture', modelId: 'model' }) } })) + await create() + const previous = sessions.snapshot()[0]!.id + events.commits.fire(commit(true)) + await sessions.invalidateMatching(() => true) + await create() + const current = sessions.snapshot()[0]!.id + expect(current).not.toBe(previous) + gate.resolve() + await consumer.whenIdle() + expect(sessions.snapshot()[0]!.id).toBe(current) + expect(consumer.snapshot.status).toBe('ready') + await consumer.dispose() + await sessions.dispose() + }) +}) diff --git a/apps/buddy/service/src/providers/__tests__/ProviderModelResolution.spec.ts b/apps/buddy/service/src/providers/__tests__/ProviderModelResolution.spec.ts index c6c63179..aa972cf8 100644 --- a/apps/buddy/service/src/providers/__tests__/ProviderModelResolution.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/ProviderModelResolution.spec.ts @@ -32,7 +32,7 @@ describe('provider model resolution', () => { const runtime = await ModelRuntime.create({ credentials, modelsPath: null, modelsStore: new InMemoryModelsStore(), refreshOnCreate: false }) const providers = createProviderRepository(database) const service = new ProviderService({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), credentialStatus: createProviderCredentialStatus(credentials), modelDiscovery: { supports: () => false, discover: async () => [] }, modelRuntime: createProviderModelRuntime(runtime, new ProviderRequestHeaders(providers.states)), @@ -75,7 +75,7 @@ describe('provider model resolution', () => { const setup = async () => { const runtime = await ModelRuntime.create({ credentials, modelsPath: null, modelsStore: new InMemoryModelsStore(), refreshOnCreate: false }) const service = new ProviderService({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), credentialStatus: createProviderCredentialStatus(credentials), modelDiscovery: { supports: () => false, discover: async () => [] }, modelRuntime: runtime, diff --git a/apps/buddy/service/src/providers/__tests__/ProviderModelSnapshotService.spec.ts b/apps/buddy/service/src/providers/__tests__/ProviderModelSnapshotService.spec.ts index 8837bd21..df1f94d9 100644 --- a/apps/buddy/service/src/providers/__tests__/ProviderModelSnapshotService.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/ProviderModelSnapshotService.spec.ts @@ -11,6 +11,31 @@ afterEach(async () => { }) describe('providerModelSnapshotService', () => { + it('preserves a single refresh under observer reentry and versions complete capability changes', async () => { + let next = data('before') + const service = new ProviderModelSnapshotService({ builtin, fetch: async () => Response.json(next) }) + const events: import('../ProviderModelSnapshotService').ModelMetadataChange[] = [] + let reentrant: Promise | undefined + service.onDidChange((event) => { + events.push(event) + if (event.kind === 'refresh-started') + reentrant = service.refresh() + }) + const first = service.refresh() + expect(reentrant).toBe(first) + await first + expect(events.find(event => event.kind === 'accepted')?.catalogRevision).toBe(0) + next = data('before') + next.openai.models.model.modalities.input = ['text'] + await service.refresh() + expect(events.filter(event => event.kind === 'accepted').at(-1)?.catalogRevision).toBe(1) + const accepted = events.filter(event => event.kind === 'accepted').length + await service.refresh() + expect(events.filter(event => event.kind === 'accepted')).toHaveLength(accepted) + expect(Object.isFrozen(service.getModels()[0])).toBe(true) + await service.dispose() + }) + it('coalesces one public download, persists it and restores it offline', async () => { const directory = await mkdtemp(join(tmpdir(), 'buddy-model-snapshot-')) directories.push(directory) diff --git a/apps/buddy/service/src/providers/__tests__/ProviderService.spec.ts b/apps/buddy/service/src/providers/__tests__/ProviderService.spec.ts index 75eef108..6892848b 100644 --- a/apps/buddy/service/src/providers/__tests__/ProviderService.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/ProviderService.spec.ts @@ -35,13 +35,91 @@ afterEach(async () => { }) describe('providerService', () => { + it('publishes committed configuration even when catalog application fails', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const runtime = new FakeModelRuntime() + const repository = createProviderRepository(database) + const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: repository }) + const commits: Array = [] + const stages: string[] = [] + service.onDidCommit(event => commits.push(event)) + service.onDidApplyCatalog(event => stages.push(event.stage)) + runtime.registerProvider = () => { + throw new Error('fixture application failure') + } + try { + await expect(service.createCustomProvider({ api: 'openai-completions', baseUrl: 'https://fixture.invalid/v1', displayName: 'Fixture', enabled: false, id: 'fixture', models: [] })).rejects.toThrow('fixture application failure') + expect(repository.configs.findById('fixture')).toMatchObject({ displayName: 'Fixture' }) + expect(commits).toHaveLength(1) + expect(commits[0]?.providers).toEqual([{ providerId: 'fixture', kind: 'added', executionChanged: true, enabled: false }]) + expect(stages).toEqual(['started', 'failed']) + expect(JSON.stringify(commits)).not.toContain('https://fixture.invalid') + } + finally { + await service.dispose() + database.close() + } + }) + + it('separates display changes, effective capabilities and semantic no-ops', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const runtime = new FakeModelRuntime() + const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database) }) + try { + await service.createCustomProvider({ api: 'openai-completions', baseUrl: 'https://fixture.invalid/v1', displayName: 'Fixture', enabled: false, id: 'fixture', models: [{ id: 'model', name: 'Model', input: ['text'], reasoning: false, contextWindow: 32000, maxTokens: 4000 }] }) + const commits: Array = [] + service.onDidCommit(event => commits.push(event)) + await service.renameProvider('fixture', 'Renamed') + expect(commits[0]?.providers[0]?.executionChanged).toBe(false) + expect(commits[0]?.models).toEqual([]) + const before = service.snapshot + await service.renameProvider('fixture', 'Renamed') + expect(service.snapshot.revision).toBe(before.revision) + await service.setModelCapabilities('fixture', 'model', { image: true }) + expect(commits.at(-1)?.models[0]?.facets).toContain('execution') + expect(service.snapshot.catalogRevision).toBeGreaterThan(before.catalogRevision) + expect(before.models[0]?.capabilities).not.toContain('image') + expect(Object.isFrozen(service.snapshot.models[0]?.capabilities)).toBe(true) + } + finally { + await service.dispose() + database.close() + } + }) + + it('rejects late discovery after its provider has been removed', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const runtime = new FakeModelRuntime() + const discovered = Promise.withResolvers>>() + const started = Promise.withResolvers() + const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), modelDiscovery: createTestModelDiscovery(() => { + started.resolve() + return discovered.promise + }) }) + try { + await service.createCustomProvider({ api: 'openai-completions', baseUrl: 'https://fixture.invalid/v1', displayName: 'Fixture', enabled: false, id: 'fixture', models: [] }) + const pending = service.syncModels('fixture') + const rejected = expect(pending).rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE' }) + await started.promise + await service.removeProvider('fixture') + discovered.resolve([]) + await rejected + expect(service.snapshot.providers).toEqual([]) + expect(service.snapshot.models).toEqual([]) + } + finally { + await service.dispose() + database.close() + } + }) + it('rejects custom creation collisions without overwriting provider configuration', async () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) try { const runtime = new FakeModelRuntime() runtime.providers = [provider('xiaomi')] const repository = createProviderRepository(database) - const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService({ notify: () => {} }), modelRuntime: runtime, providers: repository }) + const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: repository }) const input = { api: 'openai-completions' as const, baseUrl: 'https://proxy.example.test/v1', displayName: 'xiaomi', enabled: false, id: 'custom-xiaomi', models: [] } await expect(service.createCustomProvider({ ...input, id: 'xiaomi' })).rejects.toMatchObject({ code: 'PROVIDER_ID_CONFLICT' }) expect(repository.configs.findById('xiaomi')).toBeNull() @@ -65,7 +143,7 @@ describe('providerService', () => { runtime.credentials = runtime.providers.map(provider => ({ providerId: provider.id, type: provider.id === 'google' ? 'api_key' : 'oauth' } as CredentialInfo)) const database = openBuddyDatabase({ databasePath: ':memory:' }) try { - const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService({ notify: () => {} }), modelRuntime: runtime, providers: createProviderRepository(database), modelSnapshot: new ProviderModelSnapshotService() }) + const service = createProviderServiceForTest({ authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), modelSnapshot: new ProviderModelSnapshotService() }) await service.initializeProviders() const codex = (await service.listModels('openai-codex')).find(model => model.id === 'gpt-5.5')! expect(codex.capabilities).toContain('pdf') @@ -90,7 +168,7 @@ describe('providerService', () => { const runtime = new FakeModelRuntime() runtime.credentials = [{ providerId: 'openai-proxy', type: 'api_key' } as CredentialInfo] const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), }) @@ -170,7 +248,7 @@ describe('providerService', () => { const sessionRuntime = {} as ModelRuntime const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), sessionRuntime, @@ -200,10 +278,10 @@ describe('providerService', () => { }) runtime.credentialsError = new HostCredentialStoreError('CREDENTIAL_STORE_UNAVAILABLE') await expect(service.executionModels.resolveAvailable(selection)).rejects.toMatchObject({ - code: 'AUTHENTICATION_REQUIRED', + code: 'CREDENTIAL_STORE_UNAVAILABLE', }) await expect(service.listProviders()).resolves.toEqual([ - expect.objectContaining({ id: 'anthropic', status: 'authentication_required' }), + expect.objectContaining({ id: 'anthropic', status: 'unavailable' }), ]) runtime.credentialsError = null runtime.credentials = [{ providerId: 'anthropic', type: 'api_key' } as CredentialInfo] @@ -231,7 +309,7 @@ describe('providerService', () => { runtime.models = [model('anthropic', 'claude')] const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), }) @@ -316,7 +394,7 @@ describe('providerService', () => { }] const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelDiscovery: createTestModelDiscovery(() => Promise.resolve([{ id: 'reasoner' }])), modelRuntime: runtime, providers: createProviderRepository(database), @@ -411,7 +489,7 @@ describe('providerService', () => { { ...model('second', 'shared'), input: ['text', 'image'], reasoning: true, contextWindow: 256_000 }, ] const options = { - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelDiscovery: createTestModelDiscovery(() => Promise.resolve([{ id: 'shared' }])), modelRuntime: runtime, } @@ -480,7 +558,7 @@ describe('providerService', () => { runtime.providers = [provider('origin')] runtime.models = [model('origin', 'shared')] const options = { - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelDiscovery: createTestModelDiscovery(() => Promise.resolve([{ id: 'shared' }])), modelRuntime: runtime, } @@ -534,7 +612,7 @@ describe('providerService', () => { runtime.providers = [provider('origin')] runtime.models = [model('origin', 'shared')] const options = { - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelDiscovery: createTestModelDiscovery(() => Promise.resolve([{ id: 'shared' }])), modelRuntime: runtime, } @@ -586,7 +664,7 @@ describe('providerService', () => { }] const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), getActiveRuns: () => activeRuns, modelRuntime: runtime, providers: createProviderRepository(database), @@ -638,7 +716,7 @@ describe('providerService', () => { let syncCalls = 0 const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), getActiveRuns: () => activeRuns, modelRuntime: runtime, providers: createProviderRepository(database), @@ -702,7 +780,7 @@ describe('providerService', () => { const runtime = new FakeModelRuntime() const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), modelDiscovery: createTestModelDiscovery(async ({ providerId }) => providerId === 'openai-proxy' @@ -766,7 +844,7 @@ describe('providerService', () => { let definitions = [{ id: 'current-model' }, { id: 'retired-model' }] const database = openBuddyDatabase({ databasePath: ':memory:' }) const service = createProviderServiceForTest({ - authInteractions: new AuthInteractionService({ notify: () => {} }), + authInteractions: new AuthInteractionService(), modelRuntime: runtime, providers: createProviderRepository(database), modelDiscovery: createTestModelDiscovery(async () => definitions), diff --git a/apps/buddy/service/src/providers/createProviderService.ts b/apps/buddy/service/src/providers/createProviderService.ts index 1b431b4c..b34cf56d 100644 --- a/apps/buddy/service/src/providers/createProviderService.ts +++ b/apps/buddy/service/src/providers/createProviderService.ts @@ -7,6 +7,7 @@ import { join } from 'node:path' import process from 'node:process' import { registerBunOAuthFlows } from '@earendil-works/pi-ai/bun-oauth' import { ModelRuntime } from '@earendil-works/pi-coding-agent' +import { providerNotifications } from '../../../shared/providers/providerApi' import { createProviderRepository } from '../storage/providerRepository' import { AuthInteractionService } from './AuthInteractionService' import { createProviderModelRuntime } from './createProviderModelRuntime' @@ -49,13 +50,14 @@ export async function createProviderService( const providers = options.providers ?? createProviderRepository(options.database) const requestHeaders = new ProviderRequestHeaders(providers.states) const providerRuntime = createProviderModelRuntime(modelRuntime, requestHeaders, options.record) + const authInteractions = new AuthInteractionService({ openExternal: async (url) => { + await options.peer.request('host.openExternal', { url }) + } }) + authInteractions.onDidChallenge(challenge => options.peer.notify(providerNotifications.authChallenge.method, challenge)) + authInteractions.onDidChange(change => options.record?.({ event: `provider.auth.${change.kind.replaceAll('-', '_')}`, level: 'info', operationId: change.loginId, count: change.kind === 'challenge-opened' ? 1 : 0 })) const service = new ProviderService({ - authInteractions: new AuthInteractionService({ - notify: (method, params) => options.peer.notify(method, params), - openExternal: async (url) => { - await options.peer.request('host.openExternal', { url }) - }, - }), + authInteractions, + credentials, credentialStatus: createProviderCredentialStatus(credentials), getActiveRuns: options.getActiveRuns, modelDiscovery: new OpenAiCompatibleModelDiscovery({ credentials, requestHeaders, record: options.record }), @@ -65,6 +67,36 @@ export async function createProviderService( snapshotPath: join(options.agentDirectory, 'models.dev.json'), sessionRuntime: modelRuntime, }) - await service.initializeProviders() + service.onDidCommit(event => options.peer.notify(providerNotifications.changed.method, { source: 'catalog', revision: event.revision })) + service.onDidChangeMetadata((event) => { + if (event.kind === 'refresh-completed' || event.kind === 'refresh-failed' || event.kind === 'accepted') + options.peer.notify(providerNotifications.changed.method, { source: 'metadata', revision: event.revision }) + }) + service.onDidChangeCredential((event) => { + if (event.kind === 'observation' || event.kind === 'store-availability') + options.peer.notify(providerNotifications.changed.method, { source: 'credentials', revision: event.kind === 'observation' ? event.current.revision : event.revision }) + }) + service.onDidOperate(event => options.record?.({ event: `provider.operation.${event.stage}`, level: event.stage === 'failed' ? 'warn' : 'info', operationId: event.operationId })) + service.onDidCommit(event => options.record?.({ event: 'provider.state.committed', level: 'info', operationId: event.commitId, revision: event.revision, count: event.providers.length + event.models.length })) + service.onDidApplyCatalog(event => options.record?.({ event: `provider.catalog.${event.stage}`, level: event.stage === 'failed' ? 'warn' : 'info', operationId: event.operationId, revision: event.revision })) + service.onDidChangeMetadata(event => options.record?.({ event: `provider.metadata.${event.kind.replaceAll('-', '_')}`, level: event.kind.endsWith('failed') ? 'warn' : 'info', operationId: event.operationId, revision: event.catalogRevision, count: event.modelCount })) + service.onDidChangeCredential((event) => { + if (event.kind === 'observation') + options.record?.({ event: `provider.credential.${event.current.presence}`, level: event.current.presence === 'unknown' ? 'warn' : 'info', revision: event.current.revision }) + else if (event.kind === 'store-availability') + options.record?.({ event: `provider.credential_store.${event.status}`, level: event.status === 'unknown' ? 'warn' : 'info', revision: event.revision }) + else + options.record?.({ event: `provider.credential.${event.kind.replaceAll('-', '_')}`, level: 'info', operationId: event.operationId }) + }) + try { + await service.initializeProviders() + } + catch (error) { + try { + await service.dispose() + } + catch (cleanup) { throw new AggregateError([error, cleanup], 'Provider initialization failed') } + throw error + } return service } diff --git a/apps/buddy/service/src/providers/extensionModelCapabilities.ts b/apps/buddy/service/src/providers/extensionModelCapabilities.ts new file mode 100644 index 00000000..6fb39c02 --- /dev/null +++ b/apps/buddy/service/src/providers/extensionModelCapabilities.ts @@ -0,0 +1,19 @@ +import type { UsageService } from '../usage/UsageService' +import type { ProviderExecutionModelResolver } from './ProviderExecutionModelResolver' +import { extensionAgentHandler } from '../plugins/extensionAgentHandlers' + +export function createExtensionModelCapabilities(models: Pick, usage: Pick) { + return { + 'models.generateText': extensionAgentHandler('models.generateText', async (input, context) => { + const selection = input.model ?? context.model + const { model, runtime } = await models.resolveSession({ ...selection, contextWindow: null, maxTokens: null }) + context.signal.throwIfAborted() + const result = await runtime.completeSimple(model, { systemPrompt: input.system, messages: [{ role: 'user', content: input.prompt, timestamp: Date.now() }] }, { signal: context.signal, maxTokens: input.maxTokens }) + await usage.record({ createdAt: new Date().toISOString(), model: result.model, provider: result.provider, purpose: 'tool', runId: context.runId, sourceEntryId: `plugin:${context.extensionId}:${context.invocationId}:${context.callNumber}`, usage: result.usage }) + context.signal.throwIfAborted() + if (result.stopReason === 'error' || result.stopReason === 'aborted' || result.stopReason === 'length') + throw new Error('EXTENSION_MODEL_FAILED') + return { text: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), model: { providerId: model.provider, modelId: model.id } } + }), + } +} diff --git a/apps/buddy/service/src/providers/registerProviderRpc.ts b/apps/buddy/service/src/providers/registerProviderRpc.ts index 61acca37..1d0593eb 100644 --- a/apps/buddy/service/src/providers/registerProviderRpc.ts +++ b/apps/buddy/service/src/providers/registerProviderRpc.ts @@ -1,4 +1,3 @@ -import type { AutomationChangeCoordinator } from '../automations/AutomationChangeCoordinator' import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' import type { ProviderExecutionModelResolver } from './ProviderExecutionModelResolver' import type { BuddyModel } from './providerSchemas' @@ -6,15 +5,9 @@ import type { ProviderService } from './ProviderService' import { providersRpc } from '../../../shared/providers/providerApi' import { ok, registerRuntimeRequest } from '../rpc/runtimeRequest' -export interface ProviderSessionInvalidator { - invalidateAll: () => Promise -} - export interface RegisterProviderRpcOptions { - automations: Pick rpc: RuntimeRequestRegistrar service: ProviderService - sessions: ProviderSessionInvalidator } export function registerProviderRpc(options: RegisterProviderRpcOptions): () => void { @@ -26,8 +19,6 @@ export function registerProviderRpc(options: RegisterProviderRpcOptions): () => disposers.push(registerRuntimeRequest(options.rpc, providersRpc.listBuiltinPresets, () => options.service.listBuiltinPresets())) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.rename, async (input) => { const provider = await options.service.renameProvider(input.providerId, input.displayName, input.requestHeaders) - if (input.requestHeaders !== undefined) - await options.sessions.invalidateAll() return provider })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.add, async (input) => { @@ -57,27 +48,18 @@ export function registerProviderRpc(options: RegisterProviderRpcOptions): () => })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.logout, async (input) => { await options.service.logout(input.providerId) - options.automations.blockPinnedModel(input.providerId) - await options.sessions.invalidateAll() return ok() })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.clearCredential, async (input) => { await options.service.clearCredential(input.providerId) - options.automations.blockPinnedModel(input.providerId) - await options.sessions.invalidateAll() return ok() })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.remove, async (input) => { await options.service.removeProvider(input.providerId) - options.automations.blockPinnedModel(input.providerId) - await options.sessions.invalidateAll() return ok() })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.setEnabled, async (input) => { const provider = await options.service.setProviderEnabled(input.providerId, input.enabled) - if (!input.enabled) - options.automations.blockPinnedModel(input.providerId) - await options.sessions.invalidateAll() return provider })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.setModelEnabled, async (input) => { @@ -86,25 +68,18 @@ export function registerProviderRpc(options: RegisterProviderRpcOptions): () => input.modelId, input.enabled, ) - if (!input.enabled) - options.automations.blockPinnedModel(input.providerId, input.modelId) - await options.sessions.invalidateAll() return toRuntimeModelOption(options.service.executionModels, model) })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.removeModel, async (input) => { await options.service.removeModel(input.providerId, input.modelId) - options.automations.blockPinnedModel(input.providerId, input.modelId) - await options.sessions.invalidateAll() return ok() })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.setModelCatalogSource, async (input) => { const model = await options.service.setModelCatalogSource(input.providerId, input.modelId, input.source) - await options.sessions.invalidateAll() return toRuntimeModelOption(options.service.executionModels, model) })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.setModelCapabilities, async (input) => { const model = await options.service.setModelCapabilities(input.providerId, input.modelId, input.capabilities) - await options.sessions.invalidateAll() return toRuntimeModelOption(options.service.executionModels, model) })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.setModelParameters, async (input) => { @@ -134,25 +109,14 @@ export function registerProviderRpc(options: RegisterProviderRpcOptions): () => })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.syncModels, async (input) => { const models = await options.service.syncModels(input.providerId) - for (const model of models) { - if (!model.enabled || !model.available) - options.automations.blockPinnedModel(model.providerId, model.id) - } return models.map(model => toRuntimeModelOption(options.service.executionModels, model)) })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.refreshModelSnapshot, async () => { const snapshot = await options.service.refreshModelSnapshot() - const models = await options.service.listModels() - for (const model of models) { - if (!model.enabled || !model.available) - options.automations.blockPinnedModel(model.providerId, model.id) - } - await options.sessions.invalidateAll() return snapshot })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.upsertManualModel, async (input) => { const model = await options.service.upsertManualModel(input.providerId, input.model) - await options.sessions.invalidateAll() return toRuntimeModelOption(options.service.executionModels, model) })) disposers.push(registerRuntimeRequest(options.rpc, providersRpc.createCustom, async params => options.service.createCustomProvider(params))) @@ -160,7 +124,6 @@ export function registerProviderRpc(options: RegisterProviderRpcOptions): () => const provider = await options.service.upsertCustomProvider( params, ) - await options.sessions.invalidateAll() return provider })) diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index a67f01d8..6bb20aec 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -19,7 +19,8 @@ export function registerRuntimeRequest + +type RunContinuityRepository = Pick + +export class RunContinuityService { + readonly #repository: RunContinuityRepository + readonly #committed: Emitter + readonly onDidCommit: Emitter['event'] + #revision = 0 + #disposed = false + + constructor(repository: RunContinuityRepository, onObserverError: (error: unknown) => void = () => {}) { + this.#repository = repository + this.#committed = new Emitter(onObserverError) + this.onDidCommit = this.#committed.event + } + + bindSession(runId: string, sessionFile: string): boolean { + this.#requireOpen() + const run = this.#repository.findById(runId) + if (!run) + return false + if (run.piSessionFile === sessionFile) + return true + if (!this.#repository.bindSession(runId, sessionFile)) + return false + this.#publish('bound', run.conversationId, run.branchId, [runId]) + return true + } + + clearForRun(runId: string): number { + this.#requireOpen() + const run = this.#repository.findById(runId) + if (!run?.piSessionFile) + return 0 + const runIds = this.#repository.clearSessionBindingsWithReceipt(run.conversationId, run.branchId, run.piSessionFile) + if (runIds.length) + this.#publish('cleared', run.conversationId, run.branchId, runIds) + return runIds.length + } + + dispose(): void { + this.#disposed = true + this.#committed.dispose() + } + + #requireOpen(): void { + if (this.#disposed) + throw new Error('Run continuity service is stopped') + } + + #publish(kind: RunContinuityCommit['kind'], conversationId: string, branchId: string, runIds: string[]): void { + this.#revision += 1 + this.#committed.fire(copyEventSnapshot({ operationId: randomUUID(), revision: this.#revision, kind, conversationId, branchId, runIds })) + } +} diff --git a/apps/buddy/service/src/runs/RunLifecycleService.ts b/apps/buddy/service/src/runs/RunLifecycleService.ts index e6e82b7e..c9d20653 100644 --- a/apps/buddy/service/src/runs/RunLifecycleService.ts +++ b/apps/buddy/service/src/runs/RunLifecycleService.ts @@ -4,11 +4,22 @@ import type { RunEventMaintenance, RunEventWriter } from '../events/RunEventPort import type { RunPurpose, RunRecord, RunStatus } from '../storage/runRecord' import type { RunRepository } from '../storage/runRepository' import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { RunEventLogFatalError } from '../events/RunEventFailure' import { BuddyAgentRunError, readStableRunErrorCode } from './runError' type TerminalRunStatus = Extract +export type RunSqlReconciliation = Readonly<{ + runId: string + conversationId: string + branchId: string + status: TerminalRunStatus + errorCode: string | null + completedAt: string +}> + export interface StartRunInput { expectedPurposes: readonly RunPurpose[] payload: Record @@ -36,12 +47,16 @@ export interface RunLifecycleServiceOptions { } export class RunLifecycleService { - readonly #record: ApplicationDiagnosticReporter + readonly #reconciled: Emitter + readonly onDidReconcile: Emitter['event'] readonly #eventLog: RunLifecycleServiceOptions['eventLog'] readonly #repository: RunLifecycleServiceOptions['repository'] + readonly #pending = new Set>() + #stopping = false constructor(options: RunLifecycleServiceOptions) { - this.#record = safeDiagnosticReporter(options.record) + this.#reconciled = new Emitter(() => safeDiagnosticReporter(options.record)({ event: 'observer.failed', component: 'runtime.run_lifecycle', level: 'warn' })) + this.onDidReconcile = this.#reconciled.event this.#eventLog = options.eventLog this.#repository = options.repository } @@ -50,7 +65,11 @@ export class RunLifecycleService { return this.#repository.findById(runId) } - async start(input: StartRunInput): Promise { + start(input: StartRunInput): Promise { + return this.#run(() => this.#start(structuredClone(input))) + } + + async #start(input: StartRunInput): Promise { const run = this.#requireRun(input.runId) if ( run.status !== 'queued' @@ -65,7 +84,6 @@ export class RunLifecycleService { runId: run.id, type: 'run.started', }) - this.#record({ event: 'run.started', level: 'info', runId: run.id, conversationId: run.conversationId, branchId: run.branchId }) return this.#requireRun(run.id) } @@ -75,13 +93,17 @@ export class RunLifecycleService { return null return this.finalize({ completedAt: new Date().toISOString(), - errorCode: readStableRunErrorCode(error), + errorCode: error instanceof Error && error.name === 'AbortError' ? 'RUN_CANCELLED' : readStableRunErrorCode(error), runId, - status: 'failed', + status: error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed', }) } - async finalize(input: FinalizeRunInput): Promise { + finalize(input: FinalizeRunInput): Promise { + return this.#run(() => this.#finalize(structuredClone(input))) + } + + async #finalize(input: FinalizeRunInput): Promise { const existing = this.#requireRun(input.runId) if (isTerminal(existing.status)) { if (existing.status === input.status) @@ -127,15 +149,16 @@ export class RunLifecycleService { throw new BuddyAgentRunError('RUN_STATE_MISMATCH') } const run = this.#requireRun(input.runId) - this.#record({ - event: `run.${run.status}`, - level: run.status === 'failed' ? 'error' : 'info', - runId: run.id, - conversationId: run.conversationId, - branchId: run.branchId, - durationMs: Math.max(0, Date.parse(input.completedAt) - Date.parse(run.startedAt)), - ...(run.errorCode ? { errorCode: run.errorCode } : {}), - }) + if (!terminalEventPersisted) { + this.#reconciled.fire(copyEventSnapshot({ + runId: run.id, + conversationId: run.conversationId, + branchId: run.branchId, + errorCode: run.errorCode, + status, + completedAt: input.completedAt, + })) + } if (terminalEventPersisted) { try { await this.#eventLog.compactTerminalRun(input.runId) @@ -148,6 +171,27 @@ export class RunLifecycleService { return run } + async dispose(): Promise { + this.#stopping = true + await Promise.allSettled([...this.#pending]) + this.#reconciled.dispose() + } + + #run(operation: () => Promise): Promise { + if (this.#stopping) + return Promise.reject(new Error('Run lifecycle service is stopped')) + const accepted = Promise.withResolvers() + this.#pending.add(accepted.promise) + void accepted.promise.finally(() => this.#pending.delete(accepted.promise)).catch(() => {}) + try { + void operation().then(accepted.resolve, accepted.reject) + } + catch (error) { + accepted.reject(error) + } + return accepted.promise + } + #requireRun(runId: string): RunRecord { const run = this.#repository.findById(runId) if (!run) diff --git a/apps/buddy/service/src/runs/__tests__/RunContinuityService.spec.ts b/apps/buddy/service/src/runs/__tests__/RunContinuityService.spec.ts new file mode 100644 index 00000000..a6d3424e --- /dev/null +++ b/apps/buddy/service/src/runs/__tests__/RunContinuityService.spec.ts @@ -0,0 +1,68 @@ +import type { RunContinuityCommit } from '../RunContinuityService' +import { describe, expect, it } from 'vitest' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createRunRepository } from '../../storage/runRepository' +import { RunContinuityService } from '../RunContinuityService' + +describe('run continuity commits', () => { + it('clears only the actual conversation, branch and file binding without duplicate facts', () => { + const f = fixture() + try { + f.service.bindSession('a-one', '/private/session.jsonl') + f.service.bindSession('a-one', '/private/session.jsonl') + f.service.bindSession('a-two', '/private/session.jsonl') + f.service.bindSession('a-other-branch', '/private/session.jsonl') + f.service.bindSession('a-other-file', '/private/other.jsonl') + f.service.bindSession('b-one', '/private/session.jsonl') + expect(f.events).toHaveLength(5) + expect(f.service.clearForRun('a-one')).toBe(2) + expect(f.events.at(-1)).toMatchObject({ kind: 'cleared', conversationId: 'a', branchId: 'a-root', runIds: ['a-one', 'a-two'] }) + expect(f.service.clearForRun('a-one')).toBe(0) + expect(f.events).toHaveLength(6) + expect(f.runs.findById('a-two')?.piSessionFile).toBeNull() + expect(f.runs.findById('a-other-branch')?.piSessionFile).toBe('/private/session.jsonl') + expect(f.runs.findById('a-other-file')?.piSessionFile).toBe('/private/other.jsonl') + expect(f.runs.findById('b-one')?.piSessionFile).toBe('/private/session.jsonl') + expect(JSON.stringify(f.events)).not.toContain('/private/') + expect(Reflect.set(f.events.at(-1)!.runIds, '0', 'changed')).toBe(false) + } + finally { f.close() } + }) + + it('leaves every binding intact and emits no clear when the clearing transaction rolls back', () => { + const f = fixture() + try { + f.service.bindSession('a-one', '/private/session.jsonl') + f.service.bindSession('a-two', '/private/session.jsonl') + f.database.exec('CREATE TRIGGER fail_clear BEFORE UPDATE OF pi_session_file ON runs WHEN NEW.id = \'a-two\' AND NEW.pi_session_file IS NULL BEGIN SELECT RAISE(ABORT, \'Fixture clear failure\'); END') + expect(() => f.service.clearForRun('a-one')).toThrow('Fixture clear failure') + expect(f.events.map(event => event.kind)).toEqual(['bound', 'bound']) + expect(f.runs.findById('a-one')?.piSessionFile).toBe('/private/session.jsonl') + expect(f.runs.findById('a-two')?.piSessionFile).toBe('/private/session.jsonl') + } + finally { f.close() } + }) +}) + +function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const conversations = createConversationRepository(database) + const runs = createRunRepository(database) + const now = '2026-09-28T00:00:00.000Z' + for (const id of ['a', 'b']) { + conversations.create({ id, branchId: `${id}-root`, title: id, spaceId: null, createdAt: now, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + conversations.createMessage({ id: `${id}-question`, branchId: `${id}-root`, conversationId: id, role: 'user', runId: null, content: { text: 'Fixture' }, createdAt: now }) + } + conversations.createBranch({ id: 'a-branch', conversationId: 'a', parentBranchId: 'a-root', forkedFromMessageId: 'a-question', activate: false, createdAt: now }) + for (const [id, conversationId, branchId] of [['a-one', 'a', 'a-root'], ['a-two', 'a', 'a-root'], ['a-other-branch', 'a', 'a-branch'], ['a-other-file', 'a', 'a-root'], ['b-one', 'b', 'b-root']] as const) { + runs.create({ id, conversationId, branchId, triggeringMessageId: `${conversationId}-question`, provider: 'fixture', model: 'fixture', purpose: 'chat', status: 'completed', piSessionFile: null, startedAt: now, completedAt: now, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + } + const service = new RunContinuityService(runs) + const events: RunContinuityCommit[] = [] + service.onDidCommit(event => events.push(event)) + return { database, runs, service, events, close() { + service.dispose() + database.close() + } } +} diff --git a/apps/buddy/service/src/sandbox/SandboxDirectoryPermissions.ts b/apps/buddy/service/src/sandbox/SandboxDirectoryPermissions.ts index 2083a659..30522e0f 100644 --- a/apps/buddy/service/src/sandbox/SandboxDirectoryPermissions.ts +++ b/apps/buddy/service/src/sandbox/SandboxDirectoryPermissions.ts @@ -2,28 +2,66 @@ import type { SandboxDirectoryGrant } from '../../../shared/permissions/shellSan import type { BuddyExtensionRunContext } from '../agent/extensions/BuddyExtensionRunContext' import type { DirectoryGrant } from '../directories/resolveGrantedPath' import { realpath, stat } from 'node:fs/promises' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { ShellSandboxError } from '../../../shared/permissions/shellSandbox' +export interface SandboxDirectoryPermissionChange { + readonly revision: number + readonly runId: string + readonly kind: 'granted' | 'upgraded' | 'replaced' | 'cleared' + readonly access?: 'read' | 'write' + readonly count: number +} + export class SandboxDirectoryPermissions { - readonly #runs = new WeakMap>() + readonly #runs = new Map>() + + readonly #changes = new Emitter(() => console.error('SANDBOX_PERMISSION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false + readonly #releases = new Map void>() async grant(run: BuddyExtensionRunContext, grant: SandboxDirectoryGrant): Promise { + if (this.#disposed) + throw new Error('SANDBOX_PERMISSIONS_STOPPED') run.signal.throwIfAborted() + grant = copyEventSnapshot({ access: grant.access, path: grant.path, device: grant.device, inode: grant.inode }) await validateSandboxDirectory(grant) run.signal.throwIfAborted() + if (this.#disposed) + throw new Error('SANDBOX_PERMISSIONS_STOPPED') let grants = this.#runs.get(run) if (!grants) { grants = new Map() this.#runs.set(run, grants) - run.signal.addEventListener('abort', () => this.#runs.delete(run), { once: true }) + const release = () => { + run.signal.removeEventListener('abort', release) + this.#releases.delete(run) + const count = this.#runs.get(run)?.size ?? 0 + this.#runs.delete(run) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, runId: run.runId, kind: 'cleared', count })) + } + this.#releases.set(run, release) + run.signal.addEventListener('abort', release, { once: true }) } const existing = grants.get(grant.path) - if (existing?.access !== 'write' || existing.device !== grant.device || existing.inode !== grant.inode) - grants.set(grant.path, { access: grant.access, path: grant.path, device: grant.device, inode: grant.inode }) + const sameIdentity = existing?.device === grant.device && existing.inode === grant.inode + if (sameIdentity && (existing.access === 'write' || existing.access === grant.access)) + return + grants.set(grant.path, copyEventSnapshot(grant)) + this.#changes.fire(Object.freeze({ revision: ++this.#revision, runId: run.runId, kind: !existing ? 'granted' : sameIdentity ? 'upgraded' : 'replaced', access: grant.access, count: grants.size })) + } + + dispose(): void { + this.#disposed = true + for (const release of this.#releases.values()) release() + this.#changes.dispose() } - get(run: BuddyExtensionRunContext | null): SandboxDirectoryGrant[] { - return run && !run.signal.aborted ? [...this.#runs.get(run)?.values() ?? []] : [] + get(run: BuddyExtensionRunContext | null): readonly Readonly[] { + return Object.freeze(run && !run.signal.aborted ? [...this.#runs.get(run)?.values() ?? []] : []) } getWriteGrants(run: BuddyExtensionRunContext | null): DirectoryGrant[] { diff --git a/apps/buddy/service/src/sandbox/ShellSandboxClient.ts b/apps/buddy/service/src/sandbox/ShellSandboxClient.ts index f8a7888f..22c0ffda 100644 --- a/apps/buddy/service/src/sandbox/ShellSandboxClient.ts +++ b/apps/buddy/service/src/sandbox/ShellSandboxClient.ts @@ -1,22 +1,46 @@ import type { BashOperations } from '@earendil-works/pi-coding-agent' -import type { SandboxCommand, SandboxNetworkTarget } from '../../../shared/permissions/shellSandbox' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { SandboxLifecycleEvent } from '../../../shared/permissions/sandboxLifecycle' +import type { SandboxCommand, SandboxNetworkTarget, SandboxResult } from '../../../shared/permissions/shellSandbox' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle' import { SANDBOX_RPC_TIMEOUT_MS, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxResultSchema, ShellSandboxError } from '../../../shared/permissions/shellSandbox' type ExecOptions = Parameters[2] +type SandboxClientDetails + = | { readonly kind: 'requested' } + | { readonly kind: 'cancel-requested', readonly delivery: 'sent' | 'unavailable' } + | { readonly kind: 'returned', readonly result: Readonly } + | { readonly kind: 'transport-failed' } + | { readonly kind: 'backend', readonly event: Readonly } + +export type SandboxClientEvent = EventSnapshot + +interface PendingCommand { + approve: (target: SandboxNetworkTarget) => Promise + cancel: () => void + settled: Promise +} + export class ShellSandboxClient { readonly #peer: RuntimeRpcPeerContract - readonly #pending = new Map Promise>() + readonly #pending = new Map() readonly #dispose: () => void + readonly #changes = new Emitter(() => console.error('SANDBOX_CLIENT_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false constructor(peer: RuntimeRpcPeerContract) { this.#peer = peer this.#dispose = peer.onRequest('sandbox.network', async (params) => { const { requestId, ...target } = sandboxNetworkRequestSchema.parse(params) - return { allowed: await this.#pending.get(requestId)?.(target) ?? false } + return { allowed: await this.#pending.get(requestId)?.approve(target) ?? false } }) } @@ -25,46 +49,84 @@ export class ShellSandboxClient { options: ExecOptions, approveNetwork: (target: SandboxNetworkTarget) => Promise, ): Promise<{ exitCode: number | null }> { + if (this.#disposed) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') options.signal?.throwIfAborted() const requestId = randomUUID() + let dispatched = false + let returned = false + let cancelled = false + let backendRevision = 0 + const publish = (details: SandboxClientDetails) => this.#changes.fire(copyEventSnapshot({ ...details, requestId, revision: ++this.#revision })) const cancel = () => { + if (cancelled || returned) + return + cancelled = true + let delivery: 'sent' | 'unavailable' = 'sent' try { this.#peer.notify('host.sandbox.cancel', { requestId }) } - catch {} + catch { delivery = 'unavailable' } + publish({ kind: 'cancel-requested', delivery }) } const unsubscribe = this.#peer.onNotification((method, params) => { + if (method === 'host.sandbox.lifecycle') { + const parsed = sandboxLifecycleNotificationSchema.safeParse(params) + if (parsed.success && parsed.data.requestId === requestId && parsed.data.revision > backendRevision) { + backendRevision = parsed.data.revision + const { requestId: _, ...event } = parsed.data + publish({ kind: 'backend', event }) + } + return + } if (method !== 'host.sandbox.output') return const parsed = sandboxOutputSchema.safeParse(params) - if (parsed.success && parsed.data.requestId === requestId && !options.signal?.aborted) + if (parsed.success && parsed.data.requestId === requestId && !this.#disposed && !options.signal?.aborted) options.onData(Buffer.from(parsed.data.data, 'base64')) }) - this.#pending.set(requestId, approveNetwork) + let settle!: () => void + const settled = new Promise((resolve) => { + settle = resolve + }) + this.#pending.set(requestId, { approve: approveNetwork, cancel, settled }) options.signal?.addEventListener('abort', cancel, { once: true }) try { + publish({ kind: 'requested' }) + options.signal?.throwIfAborted() + if (this.#disposed || cancelled) + throw new ShellSandboxError('SANDBOX_CANCELLED') + dispatched = true const result = sandboxResultSchema.parse(await this.#peer.request('host.sandbox.exec', { ...input, requestId }, SANDBOX_RPC_TIMEOUT_MS)) + returned = true + publish({ kind: 'returned', result }) options.signal?.throwIfAborted() if (!result.ok) throw new ShellSandboxError(result.code) return { exitCode: result.exitCode } } + catch (error) { + if (dispatched && !returned) + publish({ kind: 'transport-failed' }) + throw error + } finally { - cancel() + if (!returned) + cancel() unsubscribe() this.#pending.delete(requestId) options.signal?.removeEventListener('abort', cancel) + settle() } } - dispose(): void { - this.#dispose() - for (const requestId of this.#pending.keys()) { - try { - this.#peer.notify('host.sandbox.cancel', { requestId }) - } - catch {} + async dispose(): Promise { + if (!this.#disposed) { + this.#disposed = true + this.#dispose() + for (const pending of this.#pending.values()) pending.cancel() } - this.#pending.clear() + await Promise.all([...this.#pending.values()].map(pending => pending.settled)) + this.#changes.dispose() } } diff --git a/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts b/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts index c065b579..4cd6f0ce 100644 --- a/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts @@ -89,6 +89,25 @@ describe('run-scoped shell directory permissions', () => { } } + it('isolates authorization snapshots, suppresses duplicate grants and releases state on disposal', async () => { + const harness = createHarness() + const events: unknown[] = [] + harness.permissions.onDidChange(event => events.push(event)) + const metadata = await stat(outside, { bigint: true }) + const input = { path: outside, access: 'read' as const, device: String(metadata.dev), inode: String(metadata.ino) } + await harness.permissions.grant(harness.run, input) + input.path = sensitive + const snapshot = harness.permissions.get(harness.run) + expect(Reflect.set(snapshot[0]!, 'access', 'write')).toBe(false) + await harness.permissions.grant(harness.run, { ...snapshot[0]! }) + expect(events).toHaveLength(1) + expect(snapshot[0]).toMatchObject({ path: outside, access: 'read' }) + harness.permissions.dispose() + expect(harness.permissions.get(harness.run)).toEqual([]) + expect(events).toMatchObject([{ kind: 'granted', count: 1 }, { kind: 'cleared', count: 1 }]) + expect(JSON.stringify(events)).not.toContain(outside) + }) + it('grants read access only to the reviewed directory and keeps saved grants unchanged', async () => { const harness = createHarness() await expect(harness.invoke()).resolves.toBeUndefined() diff --git a/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts new file mode 100644 index 00000000..86dba5f0 --- /dev/null +++ b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts @@ -0,0 +1,80 @@ +import type { SandboxCommand } from '../../../../shared/permissions/shellSandbox' +import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import type { SandboxClientEvent } from '../ShellSandboxClient' +import { deferred } from '@buddy-tests/deferred' +import { describe, expect, it } from 'vitest' +import { ShellSandboxClient } from '../ShellSandboxClient' + +function fixture() { + const response = deferred() + const notifications: Array<{ method: string, params: unknown }> = [] + const listeners = new Set<(method: string, params: unknown) => void>() + let requestId = '' + const peer: RuntimeRpcPeerContract = { + notify: (method, params) => { notifications.push({ method, params }) }, + onNotification: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + onRequest: () => () => {}, + request: async (_method, params) => { + requestId = (params as SandboxCommand).requestId + return response.promise + }, + close: () => {}, + } + const client = new ShellSandboxClient(peer) + const controller = new AbortController() + const events: SandboxClientEvent[] = [] + client.onDidChange(event => events.push(event)) + const execute = () => client.exec({ command: 'private command text', cwd: '/workspace', roots: ['/workspace'], workspaceRoots: [], resourceReadRoots: [], additionalDirectories: [], readOnly: true }, { onData: () => {}, signal: controller.signal }, async () => false) + return { client, controller, events, execute, notifications, response, backend(revision: number, kind: string, result?: unknown) { + for (const listener of listeners) listener('host.sandbox.lifecycle', { requestId, revision, kind, snapshot: { phase: kind === 'settled' ? 'finished' : 'running', started: true, waiting: 0, cancellation: 'none', ...result ? { result } : {} } }) + } } +} + +describe('sandbox client facts', () => { + it('keeps successful settlement distinct from cleanup and ignores stale backend messages', async () => { + const f = fixture() + const pending = f.execute() + f.backend(2, 'started') + f.backend(1, 'started') + f.backend(3, 'settled', { ok: true, exitCode: 0 }) + f.response.resolve({ ok: true, exitCode: 0 }) + await expect(pending).resolves.toEqual({ exitCode: 0 }) + f.controller.abort() + await f.client.dispose() + expect(f.notifications).toEqual([]) + expect(f.events.map(event => event.kind)).toEqual(['requested', 'backend', 'backend', 'returned']) + expect(JSON.stringify(f.events)).not.toContain('private command') + const backend = f.events.find(event => event.kind === 'backend') + expect(backend?.kind === 'backend' && Object.isFrozen(backend.event.snapshot)).toBe(true) + }) + + it('reports cancellation immediately and preserves a later successful backend result', async () => { + const f = fixture() + const pending = f.execute() + const rejected = expect(pending).rejects.toThrow() + f.controller.abort() + expect(f.events.map(event => event.kind)).toEqual(['requested', 'cancel-requested']) + f.backend(1, 'settled', { ok: true, exitCode: 0 }) + f.response.resolve({ ok: true, exitCode: 0 }) + await rejected + await f.client.dispose() + expect(f.notifications.map(event => event.method)).toEqual(['host.sandbox.cancel']) + expect(f.events.at(-1)).toMatchObject({ kind: 'returned', result: { ok: true, exitCode: 0 } }) + expect(f.events.some(event => event.kind === 'transport-failed')).toBe(false) + }) + + it('reports transport uncertainty without fabricating backend settlement', async () => { + const f = fixture() + const pending = f.execute() + f.response.reject(new Error('private transport detail')) + await expect(pending).rejects.toThrow() + await f.client.dispose() + expect(f.events.map(event => event.kind)).toEqual(['requested', 'transport-failed', 'cancel-requested']) + expect(JSON.stringify(f.events)).not.toContain('private transport detail') + }) +}) diff --git a/apps/buddy/service/src/sandbox/__tests__/sandboxExecutionLifecycle.spec.ts b/apps/buddy/service/src/sandbox/__tests__/sandboxExecutionLifecycle.spec.ts index ce55acc5..c652b009 100644 --- a/apps/buddy/service/src/sandbox/__tests__/sandboxExecutionLifecycle.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/sandboxExecutionLifecycle.spec.ts @@ -2,6 +2,34 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { SandboxExecutionLifecycle } from '../sandboxExecutionLifecycle' describe('sandbox execution deadline', () => { + it('does not infer cancellation or process start from the cleanup abort signal', () => { + const controller = new AbortController() + const lifecycle = new SandboxExecutionLifecycle(controller.signal) + const events: unknown[] = [] + lifecycle.onDidChange(event => events.push(event)) + lifecycle.begin() + lifecycle.finish({ ok: false, code: 'SANDBOX_UNAVAILABLE' }) + controller.abort() + expect(lifecycle.signal.aborted).toBe(true) + expect(lifecycle.started).toBe(false) + expect(lifecycle.snapshot).toMatchObject({ phase: 'finished', started: false, cancellation: 'none' }) + expect(events).toMatchObject([{ kind: 'preparing' }, { kind: 'settled' }]) + }) + + it('keeps cancellation requests distinct from the actual backend result', () => { + const controller = new AbortController() + const lifecycle = new SandboxExecutionLifecycle(controller.signal) + const events: unknown[] = [] + lifecycle.onDidChange(event => events.push(event)) + lifecycle.begin() + lifecycle.start() + controller.abort() + expect(lifecycle.snapshot).toMatchObject({ phase: 'running', started: true, cancellation: 'requested' }) + lifecycle.finish({ ok: false, code: 'SANDBOX_CANCELLED' }) + expect(events).toMatchObject([{ kind: 'preparing' }, { kind: 'started' }, { kind: 'cancel-requested' }, { kind: 'settled' }]) + expect(Object.isFrozen(lifecycle.snapshot.result)).toBe(true) + }) + afterEach(() => vi.useRealTimers()) it('bounds preparation separately from the command timeout', async () => { diff --git a/apps/buddy/service/src/sandbox/observeSandboxClient.ts b/apps/buddy/service/src/sandbox/observeSandboxClient.ts new file mode 100644 index 00000000..5e05b150 --- /dev/null +++ b/apps/buddy/service/src/sandbox/observeSandboxClient.ts @@ -0,0 +1,20 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ShellSandboxClient } from './ShellSandboxClient' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' + +export function observeSandboxClient(source: Pick, report: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report) + return source.onDidChange((event) => { + const kind = event.kind === 'backend' ? `backend.${event.event.kind}` : event.kind + const result = event.kind === 'returned' ? event.result : event.kind === 'backend' ? event.event.snapshot.result : undefined + record({ + event: `sandbox.${kind.replaceAll('-', '_')}`, + level: event.kind === 'transport-failed' || result?.ok === false ? 'warn' : 'info', + requestId: event.requestId, + revision: event.revision, + ...(event.kind === 'backend' ? { count: event.event.snapshot.waiting, sourceSequence: event.event.revision } : {}), + ...(result?.ok === false ? { errorCode: result.code } : {}), + ...(event.kind === 'transport-failed' ? { errorCode: 'SANDBOX_TRANSPORT_FAILED' } : {}), + }) + }) +} diff --git a/apps/buddy/service/src/sandbox/runSandboxCommand.ts b/apps/buddy/service/src/sandbox/runSandboxCommand.ts index ba746200..b1af016b 100644 --- a/apps/buddy/service/src/sandbox/runSandboxCommand.ts +++ b/apps/buddy/service/src/sandbox/runSandboxCommand.ts @@ -1,3 +1,4 @@ +import type { SandboxLifecycleEvent } from '../../../shared/permissions/sandboxLifecycle' import type { SandboxNetworkTarget, SandboxProcessInput, SandboxResult } from '../../../shared/permissions/shellSandbox' import type { SandboxExecutionOptions } from './sandboxExecutionLifecycle' import { sandboxNetworkTargetSchema, ShellSandboxError } from '../../../shared/permissions/shellSandbox' @@ -6,8 +7,11 @@ import { runSrtSandbox } from './backends/srt/runSrtSandbox' import { runWindowsSandbox } from './backends/windows/runWindowsSandbox' import { SandboxExecutionLifecycle } from './sandboxExecutionLifecycle' -export async function runSandboxCommand(input: SandboxProcessInput, options: Omit): Promise { +export async function runSandboxCommand(input: SandboxProcessInput, options: Omit & { onLifecycle?: (event: Readonly) => void }): Promise { const lifecycle = new SandboxExecutionLifecycle(options.signal, input.timeout) + const subscription = options.onLifecycle ? lifecycle.onDidChange(options.onLifecycle) : undefined + lifecycle.begin() + let result: SandboxResult | undefined const decisions = new Map>() const requestNetwork = async (target: SandboxNetworkTarget) => { const parsed = sandboxNetworkTargetSchema.safeParse(target) @@ -36,18 +40,22 @@ export async function runSandboxCommand(input: SandboxProcessInput, options: Omi ? await runWindowsSandbox({ ...input, backend: input.backend }, execution) : await runSrtSandbox({ ...input, backend: input.backend }, execution) if (lifecycle.signal.aborted) - return { ok: false, code: lifecycle.timedOut ? 'SANDBOX_TIMEOUT' : 'SANDBOX_CANCELLED' } - return { ok: true, exitCode } + result = { ok: false, code: lifecycle.timedOut ? 'SANDBOX_TIMEOUT' : 'SANDBOX_CANCELLED' } + else + result = { ok: true, exitCode } + return result } catch (error) { - return { + result = { ok: false, code: lifecycle.signal.aborted ? lifecycle.timedOut ? 'SANDBOX_TIMEOUT' : 'SANDBOX_CANCELLED' : error instanceof ShellSandboxError ? error.code : lifecycle.started ? 'SANDBOX_FAILED' : 'SANDBOX_UNAVAILABLE', } + return result } finally { - lifecycle.finish() + lifecycle.finish(result) + subscription?.dispose() } } diff --git a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts index eb12b4e2..4f3f1e58 100644 --- a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts +++ b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts @@ -1,5 +1,9 @@ import type { Buffer } from 'node:buffer' -import type { SandboxNetworkTarget } from '../../../shared/permissions/shellSandbox' +import type { EventSnapshot } from '../../../shared/events/eventTypes' +import type { SandboxLifecycleEvent, SandboxLifecycleSnapshot } from '../../../shared/permissions/sandboxLifecycle' +import type { SandboxNetworkTarget, SandboxResult } from '../../../shared/permissions/shellSandbox' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export interface SandboxExecutionOptions { signal: AbortSignal @@ -10,64 +14,117 @@ export interface SandboxExecutionOptions { export class SandboxExecutionLifecycle { readonly #controller = new AbortController() + readonly #changes = new Emitter>(() => console.error('SANDBOX_LIFECYCLE_OBSERVER_FAILED')) + readonly #externalSignal: AbortSignal + readonly #cancel = () => { + if (this.#phase === 'finished' || this.#cancellation !== 'none') + return + this.#cancellation = 'requested' + this.#clear() + this.#publish('cancel-requested') + } + + readonly onDidChange = this.#changes.event readonly signal: AbortSignal #timer: ReturnType | undefined #startedAt = 0 #remaining: number #waiting = 0 - #phase: 'preparing' | 'running' | 'finished' = 'preparing' - timedOut = false + #revision = 0 + #begun = false + #started = false + #phase: SandboxLifecycleSnapshot['phase'] = 'preparing' + #cancellation: SandboxLifecycleSnapshot['cancellation'] = 'none' + #result: SandboxResult | undefined constructor(signal: AbortSignal, timeoutSeconds = 30 * 60, preparationTimeoutMs = 60_000) { + this.#externalSignal = signal this.signal = AbortSignal.any([signal, this.#controller.signal]) this.#remaining = timeoutSeconds * 1_000 - this.#arm(preparationTimeoutMs) + if (signal.aborted) { + this.#cancellation = 'requested' + } + else { + signal.addEventListener('abort', this.#cancel, { once: true }) + this.#arm(preparationTimeoutMs) + } + } + + get started(): boolean { return this.#started } + get timedOut(): boolean { return this.#cancellation === 'timed-out' } + get snapshot(): EventSnapshot { + return copyEventSnapshot({ phase: this.#phase, started: this.#started, waiting: this.#waiting, cancellation: this.#cancellation, ...this.#result ? { result: this.#result } : {} }) } - get started(): boolean { return this.#phase !== 'preparing' } + begin(): void { + if (this.#begun || this.#phase === 'finished') + return + this.#begun = true + this.#publish('preparing') + } start(): void { this.signal.throwIfAborted() if (this.#phase !== 'preparing') return this.#clear() + this.#started = true this.#phase = 'running' this.#resume() + this.#publish('started') } async approve(request: () => Promise): Promise { + if (this.#phase === 'finished') + return false if (this.#waiting++ === 0 && this.#phase === 'running' && this.#timer) { this.#remaining -= performance.now() - this.#startedAt this.#clear() } + this.#publish('approval-wait') try { this.signal.throwIfAborted() const allowed = await request() return !this.signal.aborted && allowed } finally { - this.#waiting-- - this.#resume() + this.#waiting = Math.max(0, this.#waiting - 1) + const resumed = this.#resume() + if (this.snapshot.phase !== 'finished') + this.#publish(resumed ? 'approval-resumed' : 'approval-settled') } } - finish(): void { + finish(result?: SandboxResult): void { + if (this.#phase === 'finished') + return this.#phase = 'finished' + this.#result = result ? copyEventSnapshot(result) : undefined + this.#waiting = 0 this.#clear() + this.#externalSignal.removeEventListener('abort', this.#cancel) this.#controller.abort() + this.#publish(result ? 'settled' : 'released') + this.#changes.dispose() } - #resume(): void { + #publish(kind: SandboxLifecycleEvent['kind']): void { + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, kind, snapshot: this.snapshot })) + } + + #resume(): boolean { if (this.#phase !== 'running' || this.#waiting || this.signal.aborted) - return + return false this.#startedAt = performance.now() this.#arm(this.#remaining) + return true } #arm(milliseconds: number): void { this.#timer = setTimeout(() => { - this.timedOut = true + this.#cancellation = 'timed-out' this.#controller.abort() + this.#publish('timed-out') }, Math.max(1, milliseconds)) this.#timer.unref() } diff --git a/apps/buddy/service/src/sandbox/sandboxProcess.ts b/apps/buddy/service/src/sandbox/sandboxProcess.ts index 6c6841b4..b45c74cd 100644 --- a/apps/buddy/service/src/sandbox/sandboxProcess.ts +++ b/apps/buddy/service/src/sandbox/sandboxProcess.ts @@ -21,6 +21,7 @@ peer.onRequest('sandbox.exec', async (params) => { const input = sandboxProcessInputSchema.parse(params) return runSandboxCommand(input, { signal: controller.signal, + onLifecycle: event => peer.notify('sandbox.lifecycle', { ...event, requestId: input.requestId }), onData(data) { for (let offset = 0; offset < data.length; offset += 64 * 1024) peer.notify('sandbox.output', { requestId: input.requestId, data: data.subarray(offset, offset + 64 * 1024).toString('base64') }) diff --git a/apps/buddy/service/src/sandbox/shellCapability.ts b/apps/buddy/service/src/sandbox/shellCapability.ts index 1a7c2f4a..aeb85c9c 100644 --- a/apps/buddy/service/src/sandbox/shellCapability.ts +++ b/apps/buddy/service/src/sandbox/shellCapability.ts @@ -78,7 +78,7 @@ export function createShellCapability(options: { readOnly: options.execution.readOnly, roots: [...new Set(options.getGrants().map(grant => grant.canonicalRoot))], workspaceRoots: options.getGrants().filter(grant => grant.kind === 'workspace').map(grant => grant.canonicalRoot), - additionalDirectories: options.directoryPermissions.get(run), + additionalDirectories: [...options.directoryPermissions.get(run)], resourceReadRoots: [...(options.resourceReadRoots ?? [])], timeout: execOptions.timeout, }, { ...execOptions, signal: executionSignal }, async (target) => { diff --git a/apps/buddy/service/src/skills/SkillInspector.ts b/apps/buddy/service/src/skills/SkillInspector.ts index 99b3a13c..6abff32a 100644 --- a/apps/buddy/service/src/skills/SkillInspector.ts +++ b/apps/buddy/service/src/skills/SkillInspector.ts @@ -5,6 +5,7 @@ import type { SkillRepository } from '../storage/skillRepository' import type { SpaceRepository } from '../storage/spaceRepository' import { readdir, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { readFilePreview } from '../files/readFilePreview' import { isWithin, readSkillDocument, requireSkillPath, SkillError } from './skillFiles' @@ -34,20 +35,20 @@ export class SkillInspector { } remember(spaceId: string | null, scopeKey: string, catalog: LocalSkillCatalog) { - this.#catalogs.set(spaceId, { scopeKey, catalog }) + this.#catalogs.set(spaceId, { scopeKey, catalog: copyEventSnapshot(catalog) }) } async get(spaceId: string | null, id: string): Promise { const target = await this.#target(spaceId, id) const document = await readSkillDocument(target.filePath, target.root) this.#assertCurrent(target) - return { + return copyEventSnapshot({ skill: { ...target.skill, name: document.name, description: document.description }, content: document.content, body: document.body, metadata: document.metadata, compatibility: document.compatibility, - } + }) } async listFiles(input: SkillDirectoryRequest): Promise { diff --git a/apps/buddy/service/src/skills/SkillPackageCache.ts b/apps/buddy/service/src/skills/SkillPackageCache.ts index 265a8e51..e1fb831a 100644 --- a/apps/buddy/service/src/skills/SkillPackageCache.ts +++ b/apps/buddy/service/src/skills/SkillPackageCache.ts @@ -1,11 +1,13 @@ import type { BigIntStats } from 'node:fs' +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { LoadedSkill } from './skillFiles' import { createHash } from 'node:crypto' import { lstat, readdir } from 'node:fs/promises' import { basename, dirname, join, relative } from 'node:path' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { MAX_SKILL_BYTES, MAX_SKILL_FILES, MAX_SKILL_PACKAGE_BYTES, readSkill, readSkillDocument, requireSkillPath, SkillError } from './skillFiles' -export type ResolvedSkill = Omit +export type ResolvedSkill = EventSnapshot> interface CachedPackage { signature: string @@ -18,13 +20,16 @@ export class SkillPackageCache { readonly #packages = new Map() readonly #documents = new Map() readonly #pending = new Map>() + #generation = 0 async load(filePath: string, allowedRoot: string): Promise { + const generation = this.#generation const path = await requireSkillPath(allowedRoot, filePath) + this.#assertGeneration(generation) const pending = this.#pending.get(path) if (pending) return pending - const loading = this.#load(path, allowedRoot).finally(() => { + const loading = this.#load(path, allowedRoot, generation).finally(() => { if (this.#pending.get(path) === loading) this.#pending.delete(path) }) @@ -33,8 +38,10 @@ export class SkillPackageCache { } async loadMetadata(filePath: string, allowedRoot: string): Promise { + const generation = this.#generation const path = await requireSkillPath(allowedRoot, filePath) const signature = fileSignature(path, await lstat(path, { bigint: true })) + this.#assertGeneration(generation) const cached = this.#documents.get(path) if (cached?.signature === signature) { this.#documents.delete(path) @@ -47,7 +54,8 @@ export class SkillPackageCache { throw new SkillError('SKILL_INVALID') if (fileSignature(path, await lstat(path, { bigint: true })) !== signature) throw new SkillError('SKILL_CHANGED') - const skill = { ...document, revision: document.referenceRevision } + this.#assertGeneration(generation) + const skill = copyEventSnapshot({ ...document, revision: document.referenceRevision }) this.#documents.set(path, { signature, skill }) while (this.#documents.size > MAX_CACHED_PACKAGES) this.#documents.delete(this.#documents.keys().next().value!) @@ -55,12 +63,15 @@ export class SkillPackageCache { } clear() { + this.#generation++ this.#packages.clear() this.#documents.clear() + this.#pending.clear() } - async #load(path: string, allowedRoot: string): Promise { + async #load(path: string, allowedRoot: string, generation: number): Promise { const signature = await inspectPackage(path) + this.#assertGeneration(generation) const cached = this.#packages.get(path) if (cached?.signature === signature) { this.#packages.delete(path) @@ -68,14 +79,21 @@ export class SkillPackageCache { return cached.skill } this.#packages.delete(path) - const { files: _files, modes: _modes, ...skill } = await readSkill(path, allowedRoot) + const { files: _files, modes: _modes, ...loaded } = await readSkill(path, allowedRoot) if (await inspectPackage(path) !== signature) throw new SkillError('SKILL_CHANGED') + this.#assertGeneration(generation) + const skill = copyEventSnapshot(loaded) this.#packages.set(path, { signature, skill }) while (this.#packages.size > MAX_CACHED_PACKAGES) this.#packages.delete(this.#packages.keys().next().value!) return skill } + + #assertGeneration(generation: number): void { + if (generation !== this.#generation) + throw new SkillError('SKILL_CHANGED') + } } async function inspectPackage(path: string): Promise { diff --git a/apps/buddy/service/src/skills/SkillService.ts b/apps/buddy/service/src/skills/SkillService.ts index dca09091..09305010 100644 --- a/apps/buddy/service/src/skills/SkillService.ts +++ b/apps/buddy/service/src/skills/SkillService.ts @@ -1,13 +1,17 @@ +import type { Event, ListenerErrorHandler } from '../../../shared/events/Emitter' import type { LocalSkill, LocalSkillCatalog, SkillDirectoryRequest, SkillFileTarget, SkillInstallPreview, SkillOrigin, SkillPreviewInput, SkillReference } from '../../../shared/skills/skillApi' import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' import type { BuddyDataPaths } from '../storage/BuddyDataPaths' import type { SkillInstallation, SkillRepository } from '../storage/skillRepository' import type { SpaceRepository } from '../storage/spaceRepository' +import type { SkillEvent, SkillEventDetails } from './skillEvents' import type { LoadedSkill } from './skillFiles' import type { ResolvedSkill } from './SkillPackageCache' import { createHash, randomUUID } from 'node:crypto' import { mkdir, readdir, realpath, rm } from 'node:fs/promises' import { basename, dirname, join, relative } from 'node:path' +import { Emitter, filterEvent } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { isSkillAvailable, skillsRpc } from '../../../shared/skills/skillApi' import { registerRuntimeRequest } from '../rpc/runtimeRequest' import { discoverSkillFiles, readSkill, requireSkillPath, SkillError, skillIdentity } from './skillFiles' @@ -16,12 +20,12 @@ import { SkillInspector } from './SkillInspector' import { SkillPackageCache } from './SkillPackageCache' export interface BuddySkillResolution { - diagnostics: LocalSkillCatalog['diagnostics'] - paths: string[] - readRoots: string[] - references: SkillReference[] - revision: string - skills: LocalSkill[] + readonly diagnostics: LocalSkillCatalog['diagnostics'] + readonly paths: readonly string[] + readonly readRoots: readonly string[] + readonly references: readonly SkillReference[] + readonly revision: string + readonly skills: readonly LocalSkill[] } export interface BuddyMaterializedSkill { @@ -38,7 +42,7 @@ interface SkillServiceOptions { spaces: SpaceRepository repository: SkillRepository paths: BuddyDataPaths - changed?: (spaceId: string | null) => Promise | unknown + onListenerError?: ListenerErrorHandler } interface Candidate { @@ -50,8 +54,8 @@ interface Candidate { } interface ResolvedCatalog { - candidates: Candidate[] - catalog: LocalSkillCatalog + readonly candidates: readonly Readonly[] + readonly catalog: LocalSkillCatalog } interface ImportPreview { @@ -68,14 +72,36 @@ export class SkillService { readonly #packages = new SkillPackageCache() readonly #resolutions = new Map>() readonly #resolved = new Map() + readonly #accepted = new Map() + readonly #resources = new Map() + readonly #scopeGenerations = new Map() + readonly #cleanupStates = new Map['status']>() + readonly #events: Emitter + readonly sourceId = randomUUID() #mutation: Promise = Promise.resolve() #generation = 0 + #globalGeneration = 0 + #sequence = 0 + #disposed = false + #quiescing = false + readonly #operations = new Set>() constructor(options: SkillServiceOptions) { this.#options = options + this.#events = new Emitter(options.onListenerError ?? (() => console.error('SKILL_OBSERVER_FAILED'))) this.#inspector = new SkillInspector({ ...options, refresh: spaceId => this.list(spaceId) }) } + readonly onDidChange: Event = (listener, options) => this.#events.event(listener, options) + readonly onDidCommitInstallation = filterEvent(this.onDidChange, (event): event is Extract => event.type === 'installation') + readonly onDidAcceptCatalog = filterEvent(this.onDidChange, (event): event is Extract => event.type === 'catalog') + readonly onDidChangeResources = filterEvent(this.onDidChange, (event): event is Extract => event.type === 'resources') + readonly onDidCleanup = filterEvent(this.onDidChange, (event): event is Extract => event.type === 'cleanup') + + resourceSnapshots(): readonly { readonly spaceId: string | null, readonly resolution: BuddySkillResolution }[] { + return Object.freeze([...this.#resources].map(([spaceId, resolution]) => Object.freeze({ spaceId, resolution }))) + } + async initialize() { await this.#cleanup() const imports = join(this.#options.paths.root, 'skill-imports') @@ -92,31 +118,31 @@ export class SkillService { } } - async list(spaceId: string | null, metadataOnly = false): Promise { - this.#invalidateResolutions(spaceId) - return (await this.#resolve(spaceId, metadataOnly)).catalog + list(spaceId: string | null, metadataOnly = false): Promise { + if (this.#quiescing) + return Promise.reject(new SkillError('SKILL_CHANGED')) + return this.#list(spaceId, metadataOnly) } - async loadForSpace(spaceId: string | null): Promise { - const { catalog, candidates } = await this.#resolve(spaceId, true) - const effective = candidates.filter(candidate => isSkillAvailable(candidate.entry)) - const revision = createHash('sha256').update(JSON.stringify(effective.map(candidate => ({ - id: candidate.entry.id, - revision: candidate.referenceRevision, - status: candidate.entry.status, - enabled: candidate.entry.enabled, - })))).digest('hex') - return { - diagnostics: catalog.diagnostics, - paths: effective.map(candidate => candidate.entry.filePath), - readRoots: [...new Set(effective.map(candidate => dirname(candidate.entry.filePath)))], - references: effective.map(candidate => reference(candidate.entry, candidate.referenceRevision)), - revision, - skills: effective.map(candidate => candidate.entry).sort((a, b) => a.name.localeCompare(b.name)), - } + #list(spaceId: string | null, metadataOnly = false): Promise { + return this.#track(async () => { + this.#invalidateResolutions(spaceId) + return (await this.#resolve(spaceId, metadataOnly)).catalog + }) } - async materializeForSpace(spaceId: string | null, selections: readonly (string | SkillReference)[]): Promise { + loadForSpace(spaceId: string | null): Promise { + return this.#track(async () => this.#sessionResolution(await this.#resolve(spaceId, true))) + } + + materializeForSpace(spaceId: string | null, selections: readonly (string | SkillReference)[]): Promise { + if (this.#quiescing) + return Promise.reject(new SkillError('SKILL_CHANGED')) + const request = copyEventSnapshot(selections) + return this.#track(() => this.#materializeForSpace(spaceId, request)) + } + + async #materializeForSpace(spaceId: string | null, selections: readonly (string | SkillReference)[]): Promise { if (!selections.length) return [] const { candidates } = await this.#resolve(spaceId, true) @@ -176,6 +202,7 @@ export class SkillService { } setEnabled(input: { spaceId: string | null, id: string, enabled: boolean, revision: string }) { + input = copyEventSnapshot(input) return this.#mutate(async () => { const skill = await this.#currentSkill(input.spaceId, input.id) if (skill.managedBy === 'directory' || skill.spaceId !== input.spaceId) @@ -183,13 +210,16 @@ export class SkillService { if (skill.revision !== input.revision) throw new SkillError('SKILL_CHANGED') const record = this.#record(input.id) + if (record.enabled === input.enabled) + return (await this.#resolve(input.spaceId)).catalog this.#options.repository.save({ ...record, enabled: input.enabled, updatedAt: new Date().toISOString() }) - await this.#options.changed?.(input.spaceId) - return this.list(input.spaceId) + this.#installationCommitted(input.spaceId, 'enabled', [record.id]) + return this.#list(input.spaceId) }) } remove(input: { spaceId: string | null, id: string, revision: string }) { + input = copyEventSnapshot(input) return this.#mutate(async () => { const skill = await this.#currentSkill(input.spaceId, input.id) if (!skill.canRemove) @@ -198,13 +228,20 @@ export class SkillService { throw new SkillError('SKILL_CHANGED') this.#assertIdle(skill.spaceId) this.#options.repository.remove(input.id) - await this.#options.changed?.(input.spaceId) + this.#installationCommitted(input.spaceId, 'removed', [input.id]) await this.#cleanup() - return this.list(input.spaceId) + return this.#list(input.spaceId) }) } - async preview(input: SkillPreviewInput): Promise { + preview(input: SkillPreviewInput): Promise { + if (this.#quiescing) + return Promise.reject(new SkillError('SKILL_CHANGED')) + const request = copyEventSnapshot(input) + return this.#track(() => this.#preview(request)) + } + + async #preview(input: SkillPreviewInput): Promise { this.#requireSpace(input.spaceId) for (const [id, preview] of this.#previews) { if (Date.now() - preview.createdAt > 30 * 60 * 1000) @@ -212,7 +249,7 @@ export class SkillService { } if (this.#previews.size >= 8) throw new SkillError('SKILL_BUSY') - const catalog = await this.list(input.spaceId) + const catalog = await this.#list(input.spaceId) const records = this.#options.repository.list() const updating = input.updateId ? this.#record(input.updateId) : undefined if (updating && (updating.managedBy !== 'user' || updating.spaceId !== input.spaceId)) @@ -262,7 +299,9 @@ export class SkillService { } } const checkedItems = items.map(item => ({ ...item, blocked: item.blocked || items.filter(other => other.name === item.name).length > 1 })) - const result = { id, spaceId: input.spaceId, updateId: updating?.id ?? null, source: prepared.source, candidates: checkedItems, diagnostics } + const result = copyEventSnapshot({ id, spaceId: input.spaceId, updateId: updating?.id ?? null, source: prepared.source, candidates: checkedItems, diagnostics }) + if (this.#disposed) + throw new SkillError('SKILL_CHANGED') this.#previews.set(id, { directory, candidates, result, createdAt: Date.now() }) return result } @@ -275,13 +314,14 @@ export class SkillService { } install(input: { previewId: string, candidateIds: readonly string[] }) { + input = copyEventSnapshot(input) return this.#mutate(async () => { const preview = this.#previews.get(input.previewId) if (!preview || Date.now() - preview.createdAt > 30 * 60 * 1000) throw new SkillError('SKILL_PREVIEW_EXPIRED') const scope = preview.result.spaceId this.#requireSpace(scope) - const current = await this.list(scope) + const current = await this.#list(scope) const records = this.#options.repository.list() const selected = [...new Set(input.candidateIds)].map((id) => { const item = preview.result.candidates.find(candidate => candidate.id === id) @@ -301,14 +341,15 @@ export class SkillService { throw new SkillError('SKILL_INVALID') if (selected.some(candidate => candidate.existing)) this.#assertIdle(scope) - const published: string[] = [] + const published: { path: string, spaceId: string | null, installationId: string }[] = [] const next: SkillInstallation[] = [] try { for (const candidate of selected) { const id = candidate.existing?.id ?? randomUUID() const root = join(this.#options.paths.skillsDirectory(scope), id, randomUUID(), candidate.loaded.name) - published.push(dirname(root)) + published.push({ path: dirname(root), spaceId: scope, installationId: id }) this.#options.repository.scheduleCleanup(scope, id, dirname(root)) + this.#cleanupChanged({ path: dirname(root), spaceId: scope, installationId: id }, 'pending') await writeSkillFiles(root, candidate.loaded.files, candidate.loaded.modes) const source: SkillOrigin = preview.result.source.kind === 'github' ? { ...preview.result.source, subdirectory: [preview.result.source.subdirectory, candidate.sourcePath].filter(Boolean).join('/') } @@ -335,15 +376,26 @@ export class SkillService { this.#options.repository.saveAll(next) } catch (error) { - await Promise.all(published.map(path => rm(path, { recursive: true, force: true }))) + await Promise.all(published.map(async (item) => { + try { + await rm(item.path, { recursive: true, force: true }) + this.#options.repository.completeCleanup(item.path) + this.#cleanupChanged(item, 'completed') + } + catch { + this.#cleanupChanged(item, 'failed') + } + })) if (error instanceof SkillError) throw error throw new SkillError('SKILL_INSTALL_FAILED', { cause: error }) } - await this.#options.changed?.(scope) + this.#installationCommitted(scope, 'installed', next.map(record => record.id)) + for (const record of next) + this.#cleanupChanged({ path: dirname(dirname(record.path)), spaceId: scope, installationId: record.id }, 'cancelled') await this.#cleanup() await this.discard(input.previewId).catch(() => {}) - return this.list(scope) + return this.#list(scope) }) } @@ -354,41 +406,64 @@ export class SkillService { await rm(preview.directory, { recursive: true, force: true }) } + async whenIdle(): Promise { + let mutation: Promise + do { + mutation = this.#mutation + await Promise.allSettled([mutation, ...this.#operations, ...this.#resolutions.values()]) + } while (mutation !== this.#mutation || this.#operations.size || this.#resolutions.size) + } + + async quiesce(): Promise { + this.#quiescing = true + await this.whenIdle() + } + async dispose() { - await this.#mutation.catch(() => {}) - await Promise.allSettled([...this.#resolutions.values()]) + await this.quiesce() + this.#disposed = true + this.#globalGeneration = ++this.#generation this.#resolved.clear() this.#packages.clear() await Promise.all([...this.#previews.keys()].map(id => this.discard(id))) + this.#events.dispose() } async #resolve(spaceId: string | null, lightweight = false): Promise { + if (this.#disposed) + throw new SkillError('SKILL_CHANGED') const space = this.#requireSpace(spaceId) const scope = JSON.stringify(space?.primaryDirectory ?? null) const cacheId = this.#resolutionCacheId(spaceId, lightweight) const state = this.#resolutionKey(spaceId, lightweight) - const generation = this.#generation + const generation = this.#currentGeneration(spaceId) const key = JSON.stringify([state, generation]) const cached = this.#resolved.get(cacheId) if (cached?.key === key && await this.#isCatalogCurrent(cached.result)) { if (this.#resolutionKey(spaceId, lightweight) !== state) throw new SkillError('SKILL_CHANGED') - if (this.#generation === generation) + if (!this.#disposed && this.#currentGeneration(spaceId) === generation) return cached.result } - if (this.#generation !== generation) + if (this.#currentGeneration(spaceId) !== generation) return this.#resolve(spaceId, lightweight) const pending = this.#resolutions.get(key) if (pending) return pending const resolving = this.#resolveCatalog(spaceId, lightweight).then((result) => { + if (this.#disposed) + throw new SkillError('SKILL_CHANGED') if (JSON.stringify(this.#requireSpace(spaceId)?.primaryDirectory ?? null) !== scope) throw new SkillError('SKILL_CHANGED') - if (lightweight && this.#resolutionKey(spaceId, lightweight) !== state) + if (this.#currentGeneration(spaceId) !== generation) + return this.#resolve(spaceId, lightweight) + if (this.#resolutionKey(spaceId, lightweight) !== state) throw new SkillError('SKILL_CHANGED') - if (lightweight && this.#generation === generation) - this.#resolved.set(cacheId, { key, result }) - return result + const accepted = copyEventSnapshot(result) + if (lightweight) + this.#resolved.set(cacheId, { key, result: accepted }) + this.#acceptCatalog(spaceId, lightweight, scope, accepted) + return accepted }).finally(() => { if (this.#resolutions.get(key) === resolving) this.#resolutions.delete(key) @@ -401,12 +476,21 @@ export class SkillService { return JSON.stringify([spaceId, lightweight]) } - #invalidateResolutions(spaceId: string | null) { - this.#generation++ + #invalidateResolutions(spaceId: string | null, all = false) { + if (all) { + this.#globalGeneration = ++this.#generation + this.#resolved.clear() + return + } + this.#scopeGenerations.set(spaceId, ++this.#generation) this.#resolved.delete(this.#resolutionCacheId(spaceId, false)) this.#resolved.delete(this.#resolutionCacheId(spaceId, true)) } + #currentGeneration(spaceId: string | null): number { + return Math.max(this.#globalGeneration, this.#scopeGenerations.get(spaceId) ?? 0) + } + async #isCatalogCurrent(result: ResolvedCatalog): Promise { for (const candidate of result.candidates) { if (!candidate.loaded || !candidate.allowedRoot) @@ -425,7 +509,7 @@ export class SkillService { #resolutionKey(spaceId: string | null, lightweight: boolean) { const space = this.#requireSpace(spaceId) - return JSON.stringify([spaceId, JSON.stringify(space?.primaryDirectory ?? null), this.#options.repository.list(), lightweight]) + return JSON.stringify([spaceId, JSON.stringify(space?.primaryDirectory ?? null), this.#options.repository.list().filter(record => record.spaceId === null || record.spaceId === spaceId), lightweight]) } async #resolveCatalog(spaceId: string | null, lightweight = false) { @@ -492,6 +576,7 @@ export class SkillService { createdAt: existing?.createdAt ?? now, updatedAt: now, }) + this.#installationCommitted(null, 'discovered', [id]) } discovered.set(id, loaded) discoveredRoots.set(id, root) @@ -610,7 +695,6 @@ export class SkillService { const skills = [...candidates].sort((a, b) => a.entry.name.localeCompare(b.entry.name) || a.priority - b.priority || a.entry.id.localeCompare(b.entry.id)).map(candidate => candidate.entry) const revision = createHash('sha256').update(JSON.stringify(skills.map(({ id, revision, status, enabled }) => ({ id, revision, status, enabled })))).digest('hex') const catalog = { skills, diagnostics, revision } - this.#inspector.remember(spaceId, JSON.stringify(space?.primaryDirectory ?? null), catalog) return { candidates, catalog } } @@ -645,6 +729,7 @@ export class SkillService { async #cleanup() { const { repository, paths } = this.#options for (const item of repository.pendingCleanup()) { + this.#cleanupChanged(item, 'pending') const root = join(paths.skillsDirectory(item.spaceId), item.installationId) if (dirname(item.path) !== root || repository.list().some(record => dirname(dirname(record.path)) === item.path)) continue @@ -652,19 +737,93 @@ export class SkillService { await requireSkillPath(this.#options.paths.root, item.path) await rm(item.path, { recursive: true, force: true }) repository.completeCleanup(item.path) + this.#cleanupChanged(item, 'completed') } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { repository.completeCleanup(item.path) + this.#cleanupChanged(item, 'completed') + } + else { + this.#cleanupChanged(item, 'failed') + } } } } + #track(action: () => Promise): Promise { + if (this.#disposed) + return Promise.reject(new SkillError('SKILL_CHANGED')) + const pending = action().finally(() => this.#operations.delete(pending)) + this.#operations.add(pending) + return pending + } + #mutate(action: () => Promise): Promise { - const pending = this.#mutation.then(action) + if (this.#quiescing) + return Promise.reject(new SkillError('SKILL_CHANGED')) + const pending = this.#mutation.then(() => { + if (this.#disposed) + throw new SkillError('SKILL_CHANGED') + return action() + }) this.#mutation = pending.catch(() => {}) return pending } + + #installationCommitted(spaceId: string | null, reason: Extract['reason'], installationIds: readonly string[]): void { + this.#invalidateResolutions(spaceId, spaceId === null) + this.#events.fire(this.#event(spaceId, { type: 'installation', reason, installationIds, operationId: randomUUID() })) + } + + #cleanupChanged(item: { path: string, spaceId: string | null, installationId: string }, status: Extract['status']): void { + if (this.#cleanupStates.get(item.path) === status) + return + this.#cleanupStates.set(item.path, status) + this.#events.fire(this.#event(item.spaceId, { type: 'cleanup', status, installationId: item.installationId, ...(status === 'failed' ? { error: 'SKILL_CLEANUP_FAILED' as const } : {}) })) + } + + #acceptCatalog(spaceId: string | null, lightweight: boolean, scopeKey: string, result: ResolvedCatalog): void { + const cacheId = this.#resolutionCacheId(spaceId, lightweight) + const fingerprint = JSON.stringify(result.catalog) + const events: SkillEvent[] = [] + this.#inspector.remember(spaceId, scopeKey, result.catalog) + if (this.#accepted.get(cacheId) !== fingerprint) { + this.#accepted.set(cacheId, fingerprint) + events.push(this.#event(spaceId, { type: 'catalog', mode: lightweight ? 'discovery' : 'management', catalogRevision: result.catalog.revision, skillIds: result.catalog.skills.map(skill => skill.id) })) + } + if (lightweight) { + const resolution = this.#sessionResolution(result) + const previous = this.#resources.get(spaceId) + this.#resources.set(spaceId, resolution) + if (previous?.revision !== resolution.revision) + events.push(this.#event(spaceId, { type: 'resources', resourceRevision: resolution.revision, previousRevision: previous?.revision ?? null, skillIds: resolution.skills.map(skill => skill.id) })) + } + this.#events.fireBatch(events) + } + + #sessionResolution({ catalog, candidates }: ResolvedCatalog): BuddySkillResolution { + const effective = candidates.filter(candidate => isSkillAvailable(candidate.entry)) + const revision = createHash('sha256').update(JSON.stringify(effective.map(candidate => ({ + id: candidate.entry.id, + revision: candidate.referenceRevision, + status: candidate.entry.status, + enabled: candidate.entry.enabled, + filePath: candidate.entry.filePath, + })))).digest('hex') + return copyEventSnapshot({ + diagnostics: catalog.diagnostics, + paths: effective.map(candidate => candidate.entry.filePath), + readRoots: [...new Set(effective.map(candidate => dirname(candidate.entry.filePath)))], + references: effective.map(candidate => reference(candidate.entry, candidate.referenceRevision)), + revision, + skills: effective.map(candidate => candidate.entry).sort((a, b) => a.name.localeCompare(b.name)), + }) + } + + #event(spaceId: string | null, detail: SkillEventDetails): SkillEvent { + return copyEventSnapshot({ ...detail, sourceId: this.sourceId, sequence: ++this.#sequence, generation: this.#currentGeneration(spaceId), spaceId }) + } } function reference( diff --git a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts index 5a44abc6..6f8f1ce9 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts @@ -15,6 +15,33 @@ afterEach(async () => { }) describe('skillPackageCache', () => { + it.each(['metadata', 'package'] as const)('does not accept a late %s read after cache invalidation', async (mode) => { + const f = await fixture() + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + const read = boundedFile.readBoundedFile + let delayed = false + vi.spyOn(boundedFile, 'readBoundedFile').mockImplementation(async (...args) => { + const content = await read(...args) + if (!delayed && args[1] === f.path) { + delayed = true + entered.resolve() + await resume.promise + } + return content + }) + const pending = mode === 'metadata' ? f.cache.loadMetadata(f.path, f.root) : f.cache.load(f.path, f.root) + const rejected = expect(pending).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + await entered.promise + f.cache.clear() + resume.resolve() + await rejected + const current = mode === 'metadata' ? await f.cache.loadMetadata(f.path, f.root) : await f.cache.load(f.path, f.root) + expect(current.name).toBe('workflow') + expect(Reflect.set(current, 'description', 'mutated')).toBe(false) + expect((mode === 'metadata' ? await f.cache.loadMetadata(f.path, f.root) : await f.cache.load(f.path, f.root)).description).toBe('A local workflow') + }) + it('loads skill metadata without traversing bundled resources', async () => { const f = await fixture() await rm(join(f.root, 'references'), { recursive: true }) diff --git a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts index 00a4b3e3..a8b85a88 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts @@ -1,14 +1,18 @@ import type { DatabaseSync } from 'node:sqlite' +import type { SkillEvent } from '../skillEvents' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import * as boundedFile from '../../../../platform/filesystem/boundedFile' +import { SessionResourceReconciler } from '../../agent/resources/SessionResourceReconciler' +import { BuddySessionRegistry } from '../../agent/sessions/BuddySessionRegistry' import { BuddyDataPaths } from '../../storage/BuddyDataPaths' import { openBuddyDatabase } from '../../storage/database' import { createSkillRepository } from '../../storage/skillRepository' import { createSpaceRepository } from '../../storage/spaceRepository' +import { observeSkillDiagnostics } from '../observeSkillEvents' import { formatBuddySkillPrompt, SkillService } from '../SkillService' const databases: DatabaseSync[] = [] @@ -23,6 +27,138 @@ afterEach(async () => { }) describe('skillService', () => { + it('delivers an accepted installation commit to its session consumer before shutdown', async () => { + const fixture = await createFixture() + await writeSkill(fixture.global, 'late-commit', 'fixture metadata') + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const reconciler = new SessionResourceReconciler({ sessions, skills: fixture.service }) + const catalog = await fixture.service.list(null) + const resolution = await fixture.service.loadForSpace(null) + let closed = 0 + await sessions.getOrCreate({ approvalPolicy: 'policy', branchId: 'branch', canonicalRoot: '/workspace', conversationId: 'conversation', executionProfile: 'workspace_write', grantRevision: 'grant', resourceRevision: 'resource', skillRevision: resolution.revision, scratchRoot: '/scratch', sessionMode: 'interactive', spaceId: null }, null, async () => ({ piSessionFile: '/session', session: { shutdown: async () => { + closed++ + } } })) + await reconciler.whenIdle() + const skill = catalog.skills[0]! + const committed: SkillEvent[] = [] + fixture.service.onDidCommitInstallation(event => committed.push(event)) + const accepted = fixture.service.setEnabled({ spaceId: null, id: skill.id, revision: skill.revision, enabled: false }) + const stopping = reconciler.dispose() + await accepted + await stopping + expect(committed).toHaveLength(1) + expect(fixture.repository.list().find(record => record.id === skill.id)?.enabled).toBe(false) + expect(closed).toBe(1) + expect(sessions.getReady('conversation', 'branch')).toBeNull() + await expect(fixture.service.setEnabled({ spaceId: null, id: skill.id, revision: skill.revision, enabled: true })).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + await fixture.service.dispose() + await sessions.dispose() + }) + + it('accepts only the current scope generation when discovery resolves late', async () => { + const fixture = await createFixture() + await writeSkill(fixture.global, 'delayed', 'entry metadata') + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + const read = boundedFile.readBoundedFile + let delayed = false + vi.spyOn(boundedFile, 'readBoundedFile').mockImplementation(async (...args) => { + const contents = await read(...args) + if (!delayed && args[1].endsWith('SKILL.md')) { + delayed = true + entered.resolve() + await resume.promise + } + return contents + }) + const accepted: SkillEvent[] = [] + fixture.service.onDidAcceptCatalog(event => accepted.push(event)) + const old = fixture.service.list(null, true) + await entered.promise + const current = await fixture.service.list(null, true) + resume.resolve() + expect(await old).toEqual(current) + expect(accepted).toHaveLength(1) + expect(accepted[0]?.generation).toBe(2) + }) + + it('isolates returned catalogs, inspector details and effective resources from owner state', async () => { + const fixture = await createFixture() + await writeSkill(fixture.global, 'immutable', 'original metadata') + const catalog = await fixture.service.list(null) + const skill = catalog.skills[0]! + expect(Reflect.set(skill, 'name', 'overwritten')).toBe(false) + expect(Reflect.set(skill.origin!, 'location', '/untrusted')).toBe(false) + expect(() => (catalog.skills as unknown as unknown[]).pop()).toThrow() + const detail = await fixture.service.get(null, skill.id) + expect(Reflect.set(detail.skill.origin!, 'location', '/untrusted')).toBe(false) + const resolution = await fixture.service.loadForSpace(null) + expect(Reflect.set(resolution.references[0]!, 'revision', 'overwritten')).toBe(false) + expect((await fixture.service.get(null, skill.id)).skill.name).toBe('immutable') + expect((await fixture.service.loadForSpace(null)).references[0]?.revision).toBe(resolution.references[0]?.revision) + }) + + it('keeps committed installations when observers fail and emits no same-value enable change', async () => { + const failures: unknown[] = [] + const fixture = await createFixture(error => failures.push(error)) + const source = join(fixture.root, 'import-source') + await writeSkill(source, 'installed', 'private skill description') + const events: SkillEvent[] = [] + const diagnostics: unknown[] = [] + fixture.service.onDidCommitInstallation(() => { + throw new Error('private observer contents') + }) + fixture.service.onDidChange(event => events.push(event)) + observeSkillDiagnostics(fixture.service, event => diagnostics.push(event)) + const preview = await fixture.service.preview({ spaceId: null, source: { kind: 'directory', location: source } }) + const catalog = await fixture.service.install({ previewId: preview.id, candidateIds: preview.candidates.map(candidate => candidate.id) }) + const installed = catalog.skills.find(skill => skill.name === 'installed')! + expect(installed.managedBy).toBe('user') + expect(fixture.repository.list().find(record => record.id === installed.id)?.enabled).toBe(true) + expect(events.filter(event => event.type === 'installation' && event.reason === 'installed')).toHaveLength(1) + expect(events.some(event => event.type === 'cleanup' && event.status === 'cancelled')).toBe(true) + expect(failures).toHaveLength(1) + events.length = 0 + await fixture.service.setEnabled({ spaceId: null, id: installed.id, revision: installed.revision, enabled: true }) + expect(events).toEqual([]) + expect(JSON.stringify(diagnostics)).not.toContain(fixture.root) + expect(JSON.stringify(diagnostics)).not.toContain('private') + }) + + it('reconciles actual effective scope changes while retaining sessions for management-only package updates', async () => { + const fixture = await createFixture() + await writeSkill(fixture.global, 'stable-entry', 'entry document') + const guide = join(fixture.global, 'stable-entry', 'guide.md') + await writeFile(guide, 'version one') + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const reconciler = new SessionResourceReconciler({ sessions, skills: fixture.service }) + const events: SkillEvent[] = [] + fixture.service.onDidChange(event => events.push(event)) + const catalog = await fixture.service.list(null) + await reconciler.whenIdle() + const resources = await fixture.service.loadForSpace(null) + let closes = 0 + await sessions.getOrCreate({ approvalPolicy: 'policy', branchId: 'branch', canonicalRoot: '/workspace', conversationId: 'conversation', executionProfile: 'workspace_write', grantRevision: 'grant-1', resourceRevision: 'resource-1', skillRevision: resources.revision, scratchRoot: '/scratch', sessionMode: 'interactive', spaceId: null }, null, async () => ({ piSessionFile: '/session', session: { shutdown: async () => { + closes++ + } } })) + await reconciler.whenIdle() + events.length = 0 + await writeFile(guide, 'version two') + const changed = await fixture.service.list(null) + await reconciler.whenIdle() + expect(changed.revision).not.toBe(catalog.revision) + expect(events.some(event => event.type === 'catalog' && event.mode === 'management')).toBe(true) + expect(events.some(event => event.type === 'resources')).toBe(false) + expect(closes).toBe(0) + const skill = changed.skills[0]! + await fixture.service.setEnabled({ spaceId: null, id: skill.id, revision: skill.revision, enabled: false }) + await reconciler.whenIdle() + expect(closes).toBe(1) + expect(reconciler.snapshot()[0]?.status).toBe('current') + await reconciler.dispose() + await sessions.dispose() + }) + it('loads built-in, authorized-directory and global skills with stable precedence', async () => { const fixture = await createFixture() await Promise.all([ @@ -279,7 +415,7 @@ function spaceInput(id: string, root: string) { } } -async function createFixture() { +async function createFixture(onListenerError?: (error: unknown) => void) { const root = await mkdtemp(join(tmpdir(), 'lexora-buddy-skills-')) directories.push(root) const builtin = join(root, 'app', 'skills') @@ -294,7 +430,9 @@ async function createFixture() { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) const spaces = createSpaceRepository(database) + const repository = createSkillRepository(database) return { + repository, agentDirectory, builtin, global, @@ -304,7 +442,8 @@ async function createFixture() { agentDirectory, builtinSkillsDirectories: [builtin], spaces, - repository: createSkillRepository(database), + repository, + onListenerError, paths: new BuddyDataPaths(join(root, 'buddy')), }), trustedSpace, diff --git a/apps/buddy/service/src/skills/observeSkillEvents.ts b/apps/buddy/service/src/skills/observeSkillEvents.ts new file mode 100644 index 00000000..8ac02f4d --- /dev/null +++ b/apps/buddy/service/src/skills/observeSkillEvents.ts @@ -0,0 +1,34 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { SkillChangeNotice } from '../../../shared/skills/skillApi' +import type { SkillService } from './SkillService' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' + +export function observeSkillNotifications(service: Pick, notify: (event: SkillChangeNotice) => void) { + return service.onDidChange((event) => { + if (event.type !== 'catalog' && event.type !== 'installation') + return + notify(Object.freeze({ + sourceId: event.sourceId, + sequence: event.sequence, + generation: event.generation, + spaceId: event.spaceId, + type: event.type, + ...(event.type === 'catalog' ? { mode: event.mode } : {}), + })) + }) +} + +export function observeSkillDiagnostics(service: Pick, report: ApplicationDiagnosticReporter) { + const record = safeDiagnosticReporter(report) + return service.onDidChange((event) => { + const common = { component: 'runtime.skills', producerInstanceId: event.sourceId, sourceSequence: event.sequence, generation: String(event.generation) } + if (event.type === 'installation') + record({ ...common, event: `skills.installation.${event.reason}`, level: 'info', operationId: event.operationId, count: event.installationIds.length }) + else if (event.type === 'catalog') + record({ ...common, event: `skills.catalog.${event.mode}`, level: 'debug', count: event.skillIds.length }) + else if (event.type === 'resources') + record({ ...common, event: 'skills.resources.changed', level: 'debug', count: event.skillIds.length }) + else + record({ ...common, event: `skills.cleanup.${event.status}`, level: event.status === 'failed' ? 'warn' : 'debug', ...(event.error ? { errorCode: event.error } : {}) }) + }) +} diff --git a/apps/buddy/service/src/skills/skillEvents.ts b/apps/buddy/service/src/skills/skillEvents.ts new file mode 100644 index 00000000..46a48c6c --- /dev/null +++ b/apps/buddy/service/src/skills/skillEvents.ts @@ -0,0 +1,12 @@ +export interface SkillEventIdentity { + readonly sourceId: string + readonly sequence: number + readonly generation: number + readonly spaceId: string | null +} +export type SkillEventDetails + = | { readonly type: 'installation', readonly reason: 'discovered' | 'enabled' | 'installed' | 'removed', readonly installationIds: readonly string[], readonly operationId: string } + | { readonly type: 'catalog', readonly mode: 'discovery' | 'management', readonly catalogRevision: string, readonly skillIds: readonly string[] } + | { readonly type: 'resources', readonly resourceRevision: string, readonly previousRevision: string | null, readonly skillIds: readonly string[] } + | { readonly type: 'cleanup', readonly status: 'pending' | 'completed' | 'cancelled' | 'failed', readonly installationId: string, readonly error?: 'SKILL_CLEANUP_FAILED' } +export type SkillEvent = SkillEventIdentity & SkillEventDetails diff --git a/apps/buddy/service/src/spaces/SpaceDependents.ts b/apps/buddy/service/src/spaces/SpaceDependents.ts new file mode 100644 index 00000000..654da7cd --- /dev/null +++ b/apps/buddy/service/src/spaces/SpaceDependents.ts @@ -0,0 +1,146 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { EventSubscription } from '../../../shared/events/eventTypes' +import type { SessionResourceReconciler } from '../agent/resources/SessionResourceReconciler' +import type { BuddySessionRegistry, DisposableBuddySession } from '../agent/sessions/BuddySessionRegistry' +import type { AutomationChangeCoordinator } from '../automations/AutomationChangeCoordinator' +import type { DirectoryGrantService } from '../directories/DirectoryGrantService' +import type { SpaceCommit, SpaceService } from './SpaceService' +import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' + +interface SpaceDependentsOptions { + source: Pick + grants: Pick + sessions: Pick, 'snapshot' | 'onDidChange'> + resources: Pick, 'reconcileInvalidation' | 'resync' | 'snapshot'> + automations: Pick + record: ApplicationDiagnosticReporter +} + +interface ReconciliationScope { + kind: 'space' | 'directory' + id: string + revision: number + deleted: boolean + sessionIds: readonly string[] +} + +export interface SpaceDependentsStatus { + readonly status: 'ready' | 'pending' | 'degraded' | 'stopped' + readonly revision: number + readonly pending: number +} + +export class SpaceDependents { + readonly #options: SpaceDependentsOptions + readonly #subscriptions: EventSubscription[] + readonly #pending = new Set>() + readonly #affected = new Map() + readonly #retryScopes = new Map() + readonly #changes = new Emitter(() => console.error('SPACE_DEPENDENTS_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #stopped = false + + constructor(options: SpaceDependentsOptions) { + this.#options = { ...options, record: safeDiagnosticReporter(options.record) } + this.#subscriptions = [ + options.source.onDidCommit(event => this.#committed(event)), + options.grants.onDidCommit(event => this.#reconcile('directory', event.conversationId, event.revision, false)), + options.sessions.onDidChange((event) => { + if (!this.#affected.has(event.session.id)) + return + const scope = this.#affected.get(event.session.id)! + if (event.type === 'cleanup-failed') + this.#retryScopes.set(`${scope.kind}:${scope.id}`, scope) + if (event.type === 'disposed') + this.#affected.delete(event.session.id) + this.#publish() + }), + ] + } + + get snapshot(): SpaceDependentsStatus { + const affected = this.#options.sessions.snapshot().filter(session => this.#affected.has(session.id)) + const pending = this.#pending.size + affected.filter(session => session.invalidationPending || session.cleanup === 'pending').length + const failed = this.#retryScopes.size > 0 || affected.some(session => session.cleanup === 'failed') + return Object.freeze({ revision: this.#revision, pending, status: this.#stopped ? 'stopped' : failed ? 'degraded' : pending ? 'pending' : 'ready' }) + } + + async whenIdle(): Promise { + while (this.#pending.size) + await Promise.allSettled([...this.#pending]) + } + + resync(): void { + if (this.#stopped) + return + for (const scope of [...this.#retryScopes.values()]) + this.#reconcile(scope.kind, scope.id, scope.revision, scope.deleted, scope.sessionIds) + for (const space of this.#options.source.list()) { + if (space.revokedAt) + this.#reconcile('space', space.id, this.#options.source.revision, true) + } + } + + async dispose(): Promise { + await this.#options.grants.quiesce() + await this.#options.source.quiesce() + await this.whenIdle() + this.#stopped = true + for (const subscription of this.#subscriptions) subscription.dispose() + await this.whenIdle() + this.#publish() + this.#changes.dispose() + } + + #committed(event: SpaceCommit): void { + if (event.kind !== 'created' && event.facets.some(facet => facet !== 'presentation')) + this.#reconcile('space', event.spaceId, event.revision, event.kind === 'deleted') + } + + #reconcile(kind: 'space' | 'directory', id: string, revision: number, deleted: boolean, capturedSessionIds?: readonly string[]): void { + if (this.#stopped) + return + const key = `${kind}:${id}` + const sessionIds = capturedSessionIds ?? this.#options.sessions.snapshot().filter(session => kind === 'space' ? session.identity.spaceId === id : session.identity.conversationId === id).map(session => session.id) + const scope = { kind, id, revision, deleted, sessionIds } + for (const sessionId of sessionIds) this.#affected.set(sessionId, scope) + const work = Promise.resolve().then(async () => { + const results = await Promise.allSettled([ + Promise.resolve().then(() => { + if (deleted) + this.#options.automations.blockSpace(id) + }), + (async () => { + const receipt = await this.#options.resources.reconcileInvalidation({ source: kind, scope: id, revision: String(revision), sessionIds, matches: () => true, retry: true }) + if (kind === 'space' && !deleted) { + await this.#options.resources.resync(id) + if (this.#options.resources.snapshot().some(scope => scope.spaceId === id && scope.status === 'degraded')) + throw new Error('SPACE_RESOURCES_DEGRADED') + } + if (receipt.degraded) + throw new Error('SPACE_DEPENDENTS_CLEANUP_FAILED') + })(), + ]) + if (results.some(result => result.status === 'rejected')) + throw new Error('SPACE_DEPENDENTS_RECONCILIATION_FAILED') + if ((this.#retryScopes.get(key)?.revision ?? 0) <= revision) + this.#retryScopes.delete(key) + }).catch(() => { + if ((this.#retryScopes.get(key)?.revision ?? 0) <= revision) + this.#retryScopes.set(key, scope) + this.#options.record({ event: 'directory.dependencies.degraded', level: 'warn', ...(kind === 'space' ? { spaceId: id } : { conversationId: id }) }) + }).finally(() => { + this.#pending.delete(work) + this.#publish() + }) + this.#pending.add(work) + this.#publish() + } + + #publish(): void { + this.#revision++ + this.#changes.fire(this.snapshot) + } +} diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index eeff615b..9d3a7d88 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -1,17 +1,31 @@ import type { LocalSpaceDirectoryPage, LocalSpaceFileEntry, LocalSpaceFilePreview, SpaceDirectoryRequest, SpaceFileTarget, SpaceSaveDocument, SpaceSaveResult, SpaceTextDocument } from '../../../shared/spaces/spaceFileApi' import type { SpaceRepository } from '../storage/spaceRepository' -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { readdir, stat } from 'node:fs/promises' import { isAbsolute, resolve } from 'node:path' import { readBoundedFile } from '../../../platform/filesystem/boundedFile' import { saveBoundedTextFile } from '../../../platform/filesystem/saveBoundedTextFile' +import { Emitter } from '../../../shared/events/Emitter' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { readFilePreview } from '../files/readFilePreview' import { BuddyServiceError } from '../rpc/runtimeRequest' import { requireActiveSpace } from './requireActiveSpace' +export interface SpaceFileChange { + readonly revision: number + readonly operationId: string + readonly spaceId: string + readonly directoryId: string + readonly directoryRevision: number + readonly kind: 'saved' | 'conflict' | 'response-denied' +} + export class SpaceFileService { readonly #spaces: Pick + readonly #changes = new Emitter(() => console.error('SPACE_FILE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false readonly #saves = new Map>() constructor(spaces: Pick) { @@ -72,16 +86,30 @@ export class SpaceFileService { } saveDocument(input: SpaceSaveDocument): Promise { + if (this.#disposed) + return Promise.reject(new Error('SPACE_FILES_STOPPED')) + input = { ...input } + const operationId = randomUUID() + const publish = (kind: SpaceFileChange['kind']) => this.#changes.fire(Object.freeze({ revision: ++this.#revision, operationId, spaceId: input.spaceId, directoryId: input.directoryId, directoryRevision: input.revision, kind })) const key = JSON.stringify([input.directoryId, input.revision, input.path]) const previous = this.#saves.get(key) ?? Promise.resolve() const save = previous.catch(() => {}).then(async (): Promise => { const target = await this.resolve(input) const current = await this.readDocument(input) - if (current.etag !== input.etag) + if (current.etag !== input.etag) { + publish('conflict') return { status: 'conflict', document: current } + } this.requireDirectory(input) const status = await saveBoundedTextFile({ ...target, expected: current.text, content: input.text }) - this.requireDirectory(input) + publish(status) + try { + this.requireDirectory(input) + } + catch (error) { + publish('response-denied') + throw error + } return status === 'saved' ? { status, document: { text: input.text, etag: createHash('sha256').update(input.text).digest('hex') } } : { status, document: await this.readDocument(input) } @@ -93,6 +121,12 @@ export class SpaceFileService { return save } + async dispose(): Promise { + this.#disposed = true + await Promise.allSettled([...this.#saves.values()]) + this.#changes.dispose() + } + private requireDirectory(input: SpaceFileTarget) { const space = requireActiveSpace(this.#spaces.findById(input.spaceId)) const directory = space.primaryDirectory diff --git a/apps/buddy/service/src/spaces/SpaceService.ts b/apps/buddy/service/src/spaces/SpaceService.ts index 5b842f03..0439d507 100644 --- a/apps/buddy/service/src/spaces/SpaceService.ts +++ b/apps/buddy/service/src/spaces/SpaceService.ts @@ -12,6 +12,8 @@ import { randomUUID } from 'node:crypto' import { mkdir, realpath, stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { parseDirectorySearch, searchDirectoryEntries } from '../directories/searchDirectoryEntries' import { requireActiveSpace } from './requireActiveSpace' @@ -56,14 +58,41 @@ interface DirectoryConfigurationEntry { root: string } +export interface SpaceCommit { + readonly sourceId: string + readonly revision: number + readonly spaceId: string + readonly kind: 'created' | 'updated' | 'directory-granted' | 'deleted' + readonly facets: readonly ('presentation' | 'directories' | 'memory' | 'availability')[] + readonly directories: readonly { readonly id: string, readonly revision: number, readonly role: 'primary' | 'additional', readonly resourcesTrusted: boolean }[] + readonly revokedDirectoryIds: readonly string[] +} + export class SpaceService { readonly #spaces: SpaceRepository - - constructor(spaces: SpaceRepository) { + readonly #sourceId = randomUUID() + readonly #changes: Emitter + readonly #mutations = new Map>() + readonly onDidCommit + #revision = 0 + #disposed = false + #quiescing = false + + constructor(spaces: SpaceRepository, onListenerError: (error: unknown) => void = () => console.error('SPACE_OBSERVER_FAILED')) { this.#spaces = spaces + this.#changes = new Emitter(onListenerError) + this.onDidCommit = this.#changes.event + } + + get revision(): number { return this.#revision } + + create(input: CreateSpaceInput): Promise { + const request = copyEventSnapshot(input) + return this.#serialize(randomUUID(), () => this.#create(request)) } - async create(input: CreateSpaceInput): Promise { + async #create(input: CreateSpaceInput): Promise { + this.#requireOpen() const name = requireSpaceName(input.name) if (input.primaryDirectory && !input.primaryDirectorySelectionVerified) throw new SpaceDirectoryError() @@ -72,7 +101,8 @@ export class SpaceService { additionalDirectories: [], primaryDirectory: input.primaryDirectory, }, null, createdAt) - return this.#spaces.create({ + this.#requireOpen() + const created = this.#spaces.create({ additionalDirectories: directories.additionalDirectories, createdAt, id: randomUUID(), @@ -82,9 +112,16 @@ export class SpaceService { name, primaryDirectory: directories.primaryDirectory, }) + this.#publish('created', null, created) + return created + } + + update(input: UpdateSpaceInput): Promise { + const request = copyEventSnapshot(input) + return this.#serialize(request.spaceId, () => this.#update(request)) } - async update(input: UpdateSpaceInput): Promise { + async #update(input: UpdateSpaceInput): Promise { const space = requireActiveSpace(this.#spaces.findById(input.spaceId)) const name = requireSpaceName(input.name) if ( @@ -107,7 +144,13 @@ export class SpaceService { ) { throw new SpaceHasActiveRunsError() } - return this.#spaces.update({ + this.#requireOpen() + if (!directoryConfigurationChanged(space, directories) + && name === space.name && input.memoryScope === space.memoryScope + && (input.icon ?? space.icon) === space.icon && (input.iconColor ?? space.iconColor) === space.iconColor) { + return space + } + const updated = this.#spaces.update({ additionalDirectories: directories.additionalDirectories, event: { createdAt: updatedAt, @@ -132,14 +175,22 @@ export class SpaceService { primaryDirectory: directories.primaryDirectory, updatedAt, }) + this.#publish('updated', space, updated) + return updated } - async grantAdditionalDirectory(input: { + grantAdditionalDirectory(input: { root: string spaceId: string }): Promise { + const request = copyEventSnapshot(input) + return this.#serialize(request.spaceId, () => this.#grantAdditionalDirectory(request)) + } + + async #grantAdditionalDirectory(input: { root: string, spaceId: string }): Promise { requireActiveSpace(this.#spaces.findById(input.spaceId)) const resolved = await resolveSpaceDirectory(input.root, { create: true }) + this.#requireOpen() const space = requireActiveSpace(this.#spaces.findById(input.spaceId)) const existing = [...getSpaceDirectories(space)] @@ -208,6 +259,7 @@ export class SpaceService { primaryDirectory: directories.primaryDirectory, updatedAt, }) + this.#publish('directory-granted', space, updated) const directory = updated.additionalDirectories.find( candidate => candidate.id === grantedDirectory.id, ) @@ -224,12 +276,16 @@ export class SpaceService { } } - async delete(spaceId: string): Promise { + delete(spaceId: string): Promise { + return this.#serialize(spaceId, () => this.#delete(spaceId)) + } + + #delete(spaceId: string): SpaceRecord { const space = requireActiveSpace(this.#spaces.findById(spaceId)) if (this.#spaces.hasActiveRuns(spaceId)) throw new SpaceHasActiveRunsError() const deletedAt = new Date().toISOString() - return this.#spaces.delete(spaceId, deletedAt, { + const deleted = this.#spaces.delete(spaceId, deletedAt, { createdAt: deletedAt, eventType: 'space.deleted', id: randomUUID(), @@ -240,12 +296,79 @@ export class SpaceService { }, spaceId, }) + this.#publish('deleted', space, deleted) + return deleted + } + + async quiesce(): Promise { + this.#quiescing = true + while (this.#mutations.size) + await Promise.allSettled([...this.#mutations.values()]) + } + + async dispose(): Promise { + await this.quiesce() + this.#disposed = true + this.#changes.dispose() + } + + #requireOpen(): void { + if (this.#disposed) + throw new Error('SPACE_SERVICE_STOPPED') + } + + #serialize(spaceId: string, operation: () => T | Promise): Promise { + if (this.#quiescing) + return Promise.reject(new Error('SPACE_SERVICE_STOPPED')) + this.#requireOpen() + const previous = this.#mutations.get(spaceId) ?? Promise.resolve() + const pending = previous.catch(() => {}).then(() => { + this.#requireOpen() + return operation() + }).finally(() => { + if (this.#mutations.get(spaceId) === pending) + this.#mutations.delete(spaceId) + }) + this.#mutations.set(spaceId, pending) + return pending + } + + #publish(kind: SpaceCommit['kind'], before: SpaceRecord | null, current: SpaceRecord): void { + const facets: Array = [] + if (!before || before.name !== current.name || before.icon !== current.icon || before.iconColor !== current.iconColor) + facets.push('presentation') + if (!before || before.memoryScope !== current.memoryScope) + facets.push('memory') + const directories = getSpaceDirectories(current).map(directory => ({ + id: directory.id, + revision: directory.revision, + role: directory.id === current.primaryDirectory?.id ? 'primary' as const : 'additional' as const, + resourcesTrusted: directory.id === current.primaryDirectory?.id && Boolean(current.primaryDirectory.resourcesTrustedAt), + })) + if (!before || JSON.stringify(getSpaceDirectories(before)) !== JSON.stringify(getSpaceDirectories(current))) + facets.push('directories') + if (!before || before.revokedAt !== current.revokedAt) + facets.push('availability') + this.#changes.fire(copyEventSnapshot({ + sourceId: this.#sourceId, + revision: ++this.#revision, + spaceId: current.id, + kind, + facets, + directories, + revokedDirectoryIds: before ? getSpaceDirectories(before).filter(directory => !directories.some(current => current.id === directory.id)).map(directory => directory.id) : [], + })) } list(): readonly SpaceRecord[] { return this.#spaces.list() } + isGrantCurrent(spaceId: string, grantId: string): boolean { + const space = this.#spaces.findById(spaceId) + return Boolean(space && !space.revokedAt && getSpaceDirectories(space).some(directory => directory.id === grantId && !directory.revokedAt)) + } + async searchFiles(spaceId: string, query: string, deepSearch = false): Promise { const space = requireActiveSpace(this.#spaces.findById(spaceId)) const primary = space.primaryDirectory @@ -258,6 +381,9 @@ export class SpaceService { if (!directory) return [] const result = await searchDirectoryEntries({ canonicalRoot: directory.canonicalRoot, grantId: directory.id, kind: 'workspace', root: directory.root }, parsed.path, parsed.term, deepSearch) + const current = requireActiveSpace(this.#spaces.findById(spaceId)) + if (!getSpaceDirectories(current).some(candidate => candidate.id === directory.id && candidate.revision === directory.revision && !candidate.revokedAt)) + throw new SpaceDirectoryError() return result.entries.map(entry => ({ ...entry, directoryId: directory.id, relativePath: relative(directory.canonicalRoot, entry.path), root: directory.root })) } diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceDependents.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceDependents.spec.ts new file mode 100644 index 00000000..f3ae07b6 --- /dev/null +++ b/apps/buddy/service/src/spaces/__tests__/SpaceDependents.spec.ts @@ -0,0 +1,84 @@ +import type { BuddySessionIdentity } from '../../agent/sessions/BuddySessionBlueprint' +import type { SpaceCommit } from '../SpaceService' +import { describe, expect, it, vi } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' +import { BuddySessionRegistry } from '../../agent/sessions/BuddySessionRegistry' +import { SpaceDependents } from '../SpaceDependents' + +function identity(spaceId: string): BuddySessionIdentity { + return { approvalPolicy: 'policy', branchId: 'branch', canonicalRoot: '/workspace', conversationId: `conversation-${spaceId}`, executionProfile: 'workspace_write', grantRevision: 'grant-1', resourceRevision: 'resource-1', scratchRoot: '/scratch', sessionMode: 'interactive', spaceId } +} + +function fixture() { + const changes = new Emitter(() => {}) + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + const blocks: string[] = [] + let blockFails = false + const consumer = new SpaceDependents({ + source: { quiesce: async () => {}, onDidCommit: changes.event, list: () => [], revision: 1 }, + grants: { quiesce: async () => {}, onDidCommit: new Emitter(() => {}).event }, + sessions, + resources: { + reconcileInvalidation: input => sessions.invalidateMatching(input.matches, { sessionIds: input.sessionIds ?? [] }), + resync: async () => {}, + snapshot: () => [], + }, + automations: { blockSpace: (id) => { + if (blockFails) + throw new Error('dependency unavailable') + blocks.push(id) + return [] + } }, + record: () => {}, + }) + return { consumer, sessions, blocks, failBlocks(value: boolean) { + blockFails = value + }, commit(facets: SpaceCommit['facets'], kind: SpaceCommit['kind'] = 'updated') { + changes.fire({ sourceId: 'source', revision: 1, spaceId: 'a', kind, facets, directories: [], revokedDirectoryIds: [] }) + } } +} + +describe('space dependency reactions', () => { + it('ignores presentation changes and tracks deferred cleanup only for the affected Space', async () => { + const f = fixture() + const activeIdentity = identity('a') + await f.sessions.getOrCreate(activeIdentity, null, async () => ({ piSessionFile: '/sessions/a', session: { shutdown: async () => {} } })) + await f.sessions.getOrCreate(identity('b'), null, async () => ({ piSessionFile: '/sessions/b', session: { shutdown: async () => {} } })) + const gate = Promise.withResolvers() + const running = f.sessions.withConversationRun(activeIdentity, 'run', undefined, () => gate.promise) + await vi.waitFor(() => expect(f.sessions.getActiveRun(activeIdentity)).not.toBeNull()) + f.commit(['presentation']) + await f.consumer.whenIdle() + expect(f.sessions.snapshot().some(session => session.invalidationPending)).toBe(false) + f.commit(['directories']) + await f.consumer.whenIdle() + expect(f.consumer.snapshot).toMatchObject({ status: 'pending' }) + expect(f.sessions.snapshot().find(session => session.identity.spaceId === 'b')?.invalidationPending).toBe(false) + gate.resolve() + await running + expect(f.consumer.snapshot.status).toBe('ready') + expect(f.sessions.getReady('conversation-b', 'branch')).not.toBeNull() + await f.consumer.dispose() + await f.sessions.dispose() + }) + + it('continues session invalidation after an automation reaction fails and retries only captured session identities', async () => { + const f = fixture() + await f.sessions.getOrCreate(identity('a'), null, async () => ({ piSessionFile: '/sessions/a', session: { shutdown: async () => {} } })) + f.failBlocks(true) + f.commit(['availability'], 'deleted') + await f.consumer.whenIdle() + expect(f.consumer.snapshot.status).toBe('degraded') + expect(f.sessions.getReady('conversation-a', 'branch')).toBeNull() + const replacement = { shutdown: async () => {} } + await f.sessions.getOrCreate(identity('a'), null, async () => ({ piSessionFile: '/sessions/new', session: replacement })) + f.failBlocks(false) + f.consumer.resync() + await f.consumer.whenIdle() + expect(f.blocks).toEqual(['a']) + expect(f.sessions.getReady('conversation-a', 'branch')).toBe(replacement) + expect(f.consumer.snapshot.status).toBe('ready') + await f.consumer.dispose() + await f.sessions.dispose() + }) +}) diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index eaf72329..5ba1a535 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -30,6 +30,25 @@ async function fixture() { } describe('space file browsing', () => { + it('reports a committed write before denying its response after authorization is revoked', async () => { + const f = await fixture() + const path = join(f.workspace, 'private-name.md') + await writeFile(path, 'before') + const target = { ...f.target, path: 'private-name.md' } + const original = await f.files.readDocument(target) + const events: unknown[] = [] + f.files.onDidChange((event) => { + events.push(event) + if (event.kind === 'saved') + f.database.prepare('UPDATE space_directory_bindings SET revision = revision + 1 WHERE id = ?').run(target.directoryId) + }) + await expect(f.files.saveDocument({ ...target, etag: original.etag, text: 'private changed content' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(await readFile(path, 'utf8')).toBe('private changed content') + expect(events).toMatchObject([{ kind: 'saved', revision: 1 }, { kind: 'response-denied', revision: 2 }]) + expect(JSON.stringify(events)).not.toContain('private') + expect(JSON.stringify(events)).not.toContain(original.etag) + }) + it('lists directories lazily with complete pagination and previews bounded file types', async () => { const f = await fixture() await mkdir(join(f.workspace, 'src')) diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceService.spec.ts index 51acefde..4d1f967c 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceService.spec.ts @@ -1,8 +1,11 @@ import type { DatabaseSync } from 'node:sqlite' +import type { SpaceCommit } from '../SpaceService' import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' +import { BuddySessionRegistry } from '../../agent/sessions/BuddySessionRegistry' import { prepareTestTurnRequest } from '../../storage/__tests__/composerDraftTestFixture' import { MIGRATION_TEST_TIMEOUT, openMigrationFixtureDatabase } from '../../storage/__tests__/migrationFixture' import { openBuddyDatabase } from '../../storage/database' @@ -11,6 +14,7 @@ import { BUDDY_V2_CHANGE_SCHEMA_SQL } from '../../storage/migrations/v2Change' import { BUDDY_V3_SPACE_SCHEMA_SQL } from '../../storage/migrations/v3Space' import { createRunRepository } from '../../storage/runRepository' import { createSpaceRepository } from '../../storage/spaceRepository' +import { SpaceDependents } from '../SpaceDependents' import { SpaceService } from '../SpaceService' const databases: DatabaseSync[] = [] @@ -25,6 +29,75 @@ afterEach(async () => { }) describe('spaceService', () => { + it('keeps consumers attached until an accepted asynchronous directory grant commits', async () => { + const fixture = await createFixture() + const space = await fixture.service.create(spaceInput('Space', fixture.directory)) + const sessions = new BuddySessionRegistry<{ shutdown: () => Promise }>() + let closed = 0 + await sessions.getOrCreate({ approvalPolicy: 'policy', branchId: 'branch', canonicalRoot: fixture.directory, conversationId: 'conversation', executionProfile: 'workspace_write', grantRevision: 'grant', resourceRevision: 'resources', scratchRoot: '/scratch', sessionMode: 'interactive', spaceId: space.id }, null, async () => ({ piSessionFile: '/session', session: { shutdown: async () => { + closed++ + } } })) + const consumer = new SpaceDependents({ source: fixture.service, grants: { onDidCommit: new Emitter(() => {}).event, quiesce: async () => {} }, sessions, resources: { reconcileInvalidation: input => sessions.invalidateMatching(input.matches, { sessionIds: input.sessionIds ?? [] }), resync: async () => {}, snapshot: () => [] }, automations: { blockSpace: () => [] }, record: () => {} }) + const accepted = fixture.service.grantAdditionalDirectory({ spaceId: space.id, root: join(fixture.root, 'accepted-directory') }) + const stopping = consumer.dispose() + const receipt = await accepted + await stopping + expect(fixture.service.list()[0]!.additionalDirectories.map(directory => directory.id)).toEqual([receipt.grant.id]) + expect(closed).toBe(1) + expect(consumer.snapshot.status).toBe('stopped') + await expect(fixture.service.delete(space.id)).rejects.toThrow('SPACE_SERVICE_STOPPED') + await fixture.service.dispose() + await sessions.dispose() + }) + + it('publishes only semantic committed changes and isolates observers from the stored Space', async () => { + const fixture = await createFixture() + const events: SpaceCommit[] = [] + fixture.service.onDidCommit(event => events.push(event)) + fixture.service.onDidCommit(() => { + throw new Error('observer failure') + }) + const created = await fixture.service.create(spaceInput('Private Space name', fixture.directory)) + const update = { ...spaceInput(created.name, fixture.directory), spaceId: created.id, primaryDirectory: { id: created.primaryDirectory!.id, root: fixture.directory } } + await fixture.service.update(update) + expect(events).toHaveLength(1) + await fixture.service.update({ ...update, name: 'Renamed' }) + expect(events[1]).toMatchObject({ revision: 2, kind: 'updated', facets: ['presentation'] }) + expect(Object.isFrozen(events[0]?.directories[0])).toBe(true) + expect(JSON.stringify(events)).not.toContain(fixture.directory) + expect(JSON.stringify(events)).not.toContain('Private Space name') + created.name = 'External mutation' + expect(fixture.service.list()[0]?.name).toBe('Renamed') + await fixture.service.delete(created.id) + expect(events[2]).toMatchObject({ kind: 'deleted', revokedDirectoryIds: [created.primaryDirectory!.id], directories: [] }) + }) + + it('serializes a configuration update behind a pending directory grant without losing the grant', async () => { + const fixture = await createFixture() + const created = await fixture.service.create(spaceInput('Space', fixture.directory)) + const grant = fixture.service.grantAdditionalDirectory({ spaceId: created.id, root: join(fixture.root, 'new-directory') }) + const update = fixture.service.update({ ...spaceInput('Renamed', fixture.directory), spaceId: created.id, primaryDirectory: { id: created.primaryDirectory!.id, root: fixture.directory } }) + const [mutation, updated] = await Promise.all([grant, update]) + expect(updated.additionalDirectories.map(directory => directory.id)).toEqual([mutation.grant.id]) + expect(updated.name).toBe('Renamed') + }) + + it('queues observer reentry after the committed event snapshot', async () => { + const fixture = await createFixture() + const events: SpaceCommit[] = [] + let reentered: Promise | undefined + fixture.service.onDidCommit((event) => { + if (event.kind === 'created') + reentered = fixture.service.delete(event.spaceId) + }) + fixture.service.onDidCommit(event => events.push(event)) + const created = await fixture.service.create(spaceInput('Space', fixture.directory)) + await reentered + expect(events.map(event => [event.revision, event.kind])).toEqual([[1, 'created'], [2, 'deleted']]) + expect(events[0]?.directories).toHaveLength(1) + expect(fixture.service.list()[0]).toMatchObject({ id: created.id, revokedAt: expect.any(String) }) + }) + it('creates independent Spaces that bind the same real directory', async () => { const fixture = await createFixture() @@ -236,7 +309,7 @@ async function createFixture() { database, directory, root, - service: new SpaceService(createSpaceRepository(database)), + service: new SpaceService(createSpaceRepository(database), () => {}), } } diff --git a/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts b/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts index de57fbbe..9d90f0cd 100644 --- a/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts @@ -7,12 +7,14 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { Emitter } from '../../../../shared/events/Emitter' import { AutomationChangeCoordinator } from '../../automations/AutomationChangeCoordinator' import { AutomationService } from '../../automations/AutomationService' import { createAutomationRepositories } from '../../storage/automationRepository' import { openBuddyDatabase } from '../../storage/database' import { createSpaceRepository } from '../../storage/spaceRepository' import { registerSpaceRpc } from '../registerSpaceRpc' +import { SpaceDependents } from '../SpaceDependents' import { SpaceService } from '../SpaceService' const databases: DatabaseSync[] = [] @@ -25,7 +27,7 @@ afterEach(async () => { }) describe('registerSpaceRpc', () => { - it('owns Space validation, file search, session invalidation and automation blocking', async () => { + it('serves committed Space changes consumed by session and automation services', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'lexora-buddy-space-rpc-'))) directories.push(root) const firstRoot = join(root, 'first') @@ -51,18 +53,22 @@ describe('registerSpaceRpc', () => { }, }) const harness = createRpcHarness() - registerSpaceRpc({ - automations: automationChanges, - rpc: harness.rpc, - service, - sessions: { - async invalidateSpace(spaceId) { - effects.push(`session:${spaceId}`) - return 0 + const consumer = new SpaceDependents({ + source: service, + grants: { quiesce: async () => {}, onDidCommit: new Emitter(() => {}).event }, + sessions: { snapshot: () => [], onDidChange: new Emitter(() => {}).event }, + resources: { + async reconcileInvalidation(input) { + effects.push(`session:${input.scope}`) + return { matched: 0, pending: 0, degraded: 0 } }, + async resync() {}, + snapshot: () => [], }, - spaces, + automations: automationChanges, + record: () => {}, }) + registerSpaceRpc({ rpc: harness.rpc, service }) const created = await harness.invoke('spaces.create', { memoryScope: 'space_only', @@ -87,6 +93,9 @@ describe('registerSpaceRpc', () => { draft: dailyDraft(created.id), requestId: 'create-automation', }) + await automationChanges.whenIdle() + expect(effects).toEqual([`automation:${automation.id}`, 'scheduler:wake']) + effects.length = 0 const updated = await harness.invoke('spaces.update', { memoryScope: 'personal_and_space', name: 'Renamed Space', @@ -99,26 +108,31 @@ describe('registerSpaceRpc', () => { name: 'Renamed Space', primaryDirectory: { canonicalRoot: secondRoot }, }) + await consumer.whenIdle() expect(effects).toEqual([`session:${created.id}`]) effects.length = 0 await expect(harness.invoke('spaces.delete', { spaceId: created.id })) .resolves .toEqual({ ok: true }) + await consumer.whenIdle() expect(spaces.findById(created.id)).toMatchObject({ revokedAt: expect.any(String) }) expect(automations.get(automation.id)).toMatchObject({ blockedReason: 'AUTOMATION_SPACE_UNAVAILABLE', status: 'blocked', }) - expect(effects).toEqual([ + expect([...effects].sort()).toEqual([ `automation:${automation.id}`, 'scheduler:wake', `session:${created.id}`, - ]) + ].sort()) await expect(harness.invoke('spaces.list', { unexpected: true })) .rejects .toMatchObject({ code: 'VALIDATION_FAILED' }) + await consumer.dispose() + await automationChanges.dispose() + await service.dispose() }) }) diff --git a/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts b/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts index bcdbfdba..d505b4bc 100644 --- a/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts +++ b/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts @@ -1,11 +1,9 @@ import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' -import type { SpaceRepository } from '../storage/spaceRepository' +import type { SpaceFileService } from './SpaceFileService' import { spaceFilesRpc } from '../../../shared/spaces/spaceFileApi' import { registerRuntimeRequest } from '../rpc/runtimeRequest' -import { SpaceFileService } from './SpaceFileService' -export function registerSpaceFileRpc(rpc: RuntimeRequestRegistrar, spaces: Pick): () => void { - const files = new SpaceFileService(spaces) +export function registerSpaceFileRpc(rpc: RuntimeRequestRegistrar, files: SpaceFileService): () => void { const disposers = [ registerRuntimeRequest(rpc, spaceFilesRpc.readDocument, input => files.readDocument(input)), registerRuntimeRequest(rpc, spaceFilesRpc.saveDocument, input => files.saveDocument(input)), diff --git a/apps/buddy/service/src/spaces/registerSpaceRpc.ts b/apps/buddy/service/src/spaces/registerSpaceRpc.ts index fb0709cf..220fbd9c 100644 --- a/apps/buddy/service/src/spaces/registerSpaceRpc.ts +++ b/apps/buddy/service/src/spaces/registerSpaceRpc.ts @@ -1,21 +1,11 @@ -import type { AutomationChangeCoordinator } from '../automations/AutomationChangeCoordinator' import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' -import type { SpaceRepository } from '../storage/spaceRepository' import type { SpaceService } from './SpaceService' import { spacesRpc } from '../../../shared/spaces/spaceApi' import { ok, registerRuntimeRequest } from '../rpc/runtimeRequest' -import { requireActiveSpace } from './requireActiveSpace' - -export interface SpaceSessionInvalidator { - invalidateSpace: (spaceId: string) => Promise -} export interface RegisterSpaceRpcOptions { - automations: Pick rpc: RuntimeRequestRegistrar service: SpaceService - sessions: SpaceSessionInvalidator - spaces: Pick } export function registerSpaceRpc(options: RegisterSpaceRpcOptions): () => void { @@ -24,17 +14,9 @@ export function registerSpaceRpc(options: RegisterSpaceRpcOptions): () => void { disposers.push(registerRuntimeRequest(options.rpc, spacesRpc.create, (params) => { return options.service.create(params) })) - disposers.push(registerRuntimeRequest(options.rpc, spacesRpc.update, async (input) => { - requireActiveSpace(options.spaces.findById(input.spaceId)) - const updated = await options.service.update(input) - await options.sessions.invalidateSpace(input.spaceId) - return updated - })) + disposers.push(registerRuntimeRequest(options.rpc, spacesRpc.update, input => options.service.update(input))) disposers.push(registerRuntimeRequest(options.rpc, spacesRpc.delete, async (input) => { - requireActiveSpace(options.spaces.findById(input.spaceId)) await options.service.delete(input.spaceId) - options.automations.blockSpace(input.spaceId) - await options.sessions.invalidateSpace(input.spaceId) return ok() })) disposers.push(registerRuntimeRequest(options.rpc, spacesRpc.list, (input) => { diff --git a/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts b/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts new file mode 100644 index 00000000..b31e87b8 --- /dev/null +++ b/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createConversationRepository } from '../conversationRepository' +import { openBuddyDatabase } from '../database' + +describe('task title metadata', () => { + it('preserves activity ordering and protects manual titles, concurrent writes and deleted tasks', () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + try { + const repository = createConversationRepository(database) + const createdAt = '2026-09-28T00:00:00.000Z' + for (const id of ['older', 'newer']) + repository.create({ id, branchId: `${id}-branch`, title: id, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: id === 'older' ? createdAt : '2026-09-28T01:00:00.000Z' }) + expect(repository.renameGenerated({ id: 'older', title: 'Protected', expectedRevision: 0 })).toBeNull() + database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'older') + expect(repository.renameGenerated({ id: 'older', title: 'Generated', expectedRevision: 0 })).toMatchObject({ title: 'Generated', updatedAt: createdAt }) + expect(repository.renameGenerated({ id: 'older', title: 'Late', expectedRevision: 0 })).toBeNull() + expect(repository.rename({ id: 'older', title: 'Chosen by user' })).toMatchObject({ title: 'Chosen by user', updatedAt: createdAt }) + expect(repository.getTitleState('older')).toEqual({ title: 'Chosen by user', source: 'manual', revision: 2 }) + expect(repository.renameGenerated({ id: 'older', title: 'Overwrite', expectedRevision: 2 })).toBeNull() + expect(repository.listRecent().map(task => task.id)).toEqual(['newer', 'older']) + database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'newer') + repository.markDeleted('newer', '2026-09-28T02:00:00.000Z') + expect(repository.getTitleState('newer')).toBeNull() + expect(repository.renameGenerated({ id: 'newer', title: 'Deleted', expectedRevision: 0 })).toBeNull() + } + finally { database.close() } + }) +}) diff --git a/apps/buddy/service/src/storage/__tests__/schema.spec.ts b/apps/buddy/service/src/storage/__tests__/schema.spec.ts index ca87a9b1..5ec423e8 100644 --- a/apps/buddy/service/src/storage/__tests__/schema.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/schema.spec.ts @@ -115,7 +115,7 @@ describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { const upgraded = openBuddyDatabase({ databasePath }) databases.push(upgraded) expect(upgraded.prepare('SELECT * FROM runs').all()).toEqual(runs) - expect(upgraded.prepare('SELECT * FROM conversations').all()).toEqual(conversations) + expect(upgraded.prepare('SELECT * FROM conversations').all()).toEqual(conversations.map(conversation => ({ ...conversation, title_source: 'manual', title_revision: 0 }))) expect(upgraded.prepare('SELECT * FROM skill_installations').all()).toEqual([]) expect(upgraded.prepare('PRAGMA foreign_key_check').all()).toEqual([]) }) @@ -187,7 +187,7 @@ describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { const migrated = openBuddyDatabase({ databasePath }) expect(migrated.prepare('SELECT * FROM spaces').get()).toEqual({ ...space, icon: 'folder', icon_color: 'default' }) expect(migrated.prepare('SELECT * FROM space_directory_bindings').all()).toEqual(grants) - expect(migrated.prepare('SELECT * FROM conversations').all()).toEqual(tasks) + expect(migrated.prepare('SELECT * FROM conversations').all()).toEqual(tasks.map(conversation => ({ ...conversation, title_source: 'manual', title_revision: 0 }))) expect(migrated.prepare('SELECT * FROM runs').all()).toEqual(runs) migrated.exec('UPDATE spaces SET icon = \'code\', icon_color = \'blue\' WHERE id = \'space-1\'') migrated.close() diff --git a/apps/buddy/service/src/storage/__tests__/taskMarkRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/taskMarkRepository.spec.ts index b753a278..5eb05807 100644 --- a/apps/buddy/service/src/storage/__tests__/taskMarkRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/taskMarkRepository.spec.ts @@ -184,8 +184,14 @@ describe('task marks and reading state', () => { legacy.exec(migration.sql) legacy.exec(`PRAGMA user_version = ${migration.version}`) } - seed(legacy).run('historical') - const history = legacy.prepare('SELECT * FROM conversations').all() + for (const id of ['a', 'b']) { + legacy.prepare('INSERT INTO conversations (id, title, created_at, updated_at, approval_policy, execution_profile) VALUES (?, ?, ?, ?, ?, ?)').run(id, id, now, now, 'policy', 'workspace_write') + legacy.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run(`branch-${id}`, id, now) + legacy.prepare('UPDATE conversations SET active_branch_id = ? WHERE id = ?').run(`branch-${id}`, id) + legacy.prepare('INSERT INTO messages (id, branch_id, conversation_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(`question-${id}`, `branch-${id}`, id, 'user', '{}', now) + } + createRunRepository(legacy).create({ id: 'historical', branchId: 'branch-a', conversationId: 'a', triggeringMessageId: 'question-a', provider: 'fixture', model: 'fixture', piSessionFile: null, purpose: 'chat', status: 'completed', startedAt: now, completedAt: now, approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + const history = legacy.prepare('SELECT * FROM conversations').all().map(conversation => ({ ...conversation, title_source: 'manual', title_revision: 0 })) legacy.close() const migrated = openBuddyDatabase({ databasePath }) const marks = createTaskMarkRepository(migrated) diff --git a/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts index 4faba6bf..7aba19d3 100644 --- a/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts @@ -1,6 +1,9 @@ import type { DatabaseSync } from 'node:sqlite' +import type { TurnRequestCommit } from '../../chat/TurnRequestService' import { afterEach, describe, expect, it } from 'vitest' import { createBuddyUserContent } from '../../../../shared/conversation/buddyUserContent' +import { TurnRequestService } from '../../chat/TurnRequestService' +import { createAttachmentRepository } from '../attachmentRepository' import { ComposerDraftCommitConflictError } from '../commitComposerDraft' import { createComposerDraftRepository } from '../composerDraftRepository' @@ -19,6 +22,58 @@ afterEach(() => { }) describe('turnRequestRepository', () => { + it('commits message attachment ownership in the same batch and exposes no staged paths', () => { + const database = createDatabase() + const attachments = createAttachmentRepository(database) + attachments.create({ id: 'owned-attachment', conversationId: null, draftId: 'draft-1', messageId: null, name: 'private-name.txt', mimeType: 'text/plain', sizeBytes: 1, storedPath: '/private/source.txt', createdAt: '2026-08-14T00:00:00.000Z' }) + const input = createInput() + const prepared = { ...input, runInput: { ...input.runInput, attachmentIds: ['owned-attachment'] }, attachmentBindings: [{ id: 'owned-attachment', sourceAttachmentId: 'owned-attachment', sourceDraftId: 'draft-1', messageId: input.userMessageId, storedPath: '/private/destination.txt', createdAt: input.createdAt }] } + const service = new TurnRequestService(createTurnRequestRepository(database)) + const events: TurnRequestCommit[] = [] + service.onDidCommit(event => events.push(event)) + expect(() => service.prepare({ ...prepared, draft: { draftId: 'draft-1', expectedRevision: 99 } })).toThrow(ComposerDraftCommitConflictError) + expect(events).toEqual([]) + expect(attachments.findById('owned-attachment')).toMatchObject({ draftId: 'draft-1', messageId: null }) + service.prepare(prepared) + expect(attachments.findById('owned-attachment')).toMatchObject({ draftId: null, messageId: input.userMessageId, conversationId: input.conversationId }) + expect(events[0]!.facts.find(fact => fact.kind === 'attachments.bound')).toEqual({ kind: 'attachments.bound', messageId: input.userMessageId, attachmentIds: ['owned-attachment'] }) + expect(JSON.stringify(events)).not.toMatch(/private-name|\/private\//) + service.dispose() + }) + + it('publishes one frozen transaction batch and no facts for rollback or request replay', () => { + const database = createDatabase() + const service = new TurnRequestService(createTurnRequestRepository(database)) + const events: TurnRequestCommit[] = [] + service.onDidCommit(event => events.push(event)) + const input = createInput() + expect(() => service.prepare({ ...input, draft: { draftId: 'draft-1', expectedRevision: 99 } })).toThrow(ComposerDraftCommitConflictError) + expect(events).toEqual([]) + const result = service.prepare(input) + expect(events[0]).toMatchObject({ commitId: result.runId, requestId: result.requestId, conversationId: result.conversationId, branchId: result.branchId }) + expect(events[0]!.facts.map(fact => fact.kind)).toEqual(['task.created', 'branch.created', 'task.branch_activated', 'task.model_changed', 'message.created', 'run.queued', 'draft.consumed']) + const consumed = events[0]!.facts.find(fact => fact.kind === 'draft.consumed')! + expect(Reflect.set(consumed.receipt, 'committedRevision', 9)).toBe(false) + expect(service.prepare(input)).toMatchObject({ created: false, draftReceipt: { committedRevision: 1 } }) + expect(events).toHaveLength(1) + service.dispose() + }) + + it('publishes only a new run for interrupted request retry without consuming its previous draft again', () => { + const database = createDatabase() + const service = new TurnRequestService(createTurnRequestRepository(database)) + const events: TurnRequestCommit[] = [] + service.onDidCommit(event => events.push(event)) + service.prepare(createInput()) + database.prepare('UPDATE runs SET status = \'failed\', error_code = \'RUNTIME_RESTARTED\', completed_at = ? WHERE id = ?').run('2026-08-14T00:00:01.000Z', 'run-1') + service.retryInterrupted({ requestId: 'request-1', runId: 'run-2', createdAt: '2026-08-14T00:00:02.000Z' }) + service.retryInterrupted({ requestId: 'request-1', runId: 'unused', createdAt: '2026-08-14T00:00:03.000Z' }) + expect(events.map(event => event.commitId)).toEqual(['run-1', 'run-2']) + expect(events[1]!.facts).toEqual([{ kind: 'request.retried', previousRunId: 'run-1' }, { kind: 'run.queued' }]) + expect(database.prepare('SELECT COUNT(*) AS count FROM messages').get()).toEqual({ count: 1 }) + service.dispose() + }) + it('narrows only the run profile and rejects wider overrides without consuming the draft', () => { const database = createDatabase() const repository = createTurnRequestRepository(database) diff --git a/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts index 1c478e3a..68209f49 100644 --- a/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts @@ -139,6 +139,8 @@ describe('usage analytics', () => { ALTER TABLE attachments DROP COLUMN source_path; ALTER TABLE composer_resources DROP COLUMN source_path; PRAGMA user_version = 13; + ALTER TABLE conversations DROP COLUMN title_source; + ALTER TABLE conversations DROP COLUMN title_revision; `) f.database.close() databases.splice(databases.indexOf(f.database), 1) diff --git a/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts b/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts index 7c6c739e..6138060a 100644 --- a/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts +++ b/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts @@ -18,26 +18,32 @@ export interface BlockAutomationInput { } export interface AutomationDefinitionCommandRepository { - block: (input: BlockAutomationInput) => Automation | null + block: (input: BlockAutomationInput) => AutomationDefinitionCommit | null blockActiveByPinnedModel: (input: { blockedAt: string modelId?: string providerId: string - }) => Automation[] + }) => AutomationDefinitionCommit[] blockActiveBySpace: (input: { blockedAt: string spaceId: string - }) => Automation[] - create: (automation: Automation, mutation: AutomationMutationIdentity) => Automation + }) => AutomationDefinitionCommit[] + create: (automation: Automation, mutation: AutomationMutationIdentity) => AutomationDefinitionCommit replace: (input: { automation: Automation cancelQueued: boolean expectedRevision: number - }, mutation: AutomationMutationIdentity) => Automation + }, mutation: AutomationMutationIdentity) => AutomationDefinitionCommit +} + +export interface AutomationDefinitionCommit { + automation: Automation + committed: boolean + cancelledOccurrenceIds: readonly string[] } export interface AutomationDefinitionCommandStore { - cancelQueuedOccurrences: (automationId: string, cancelledAt: string) => void + cancelQueuedOccurrences: (automationId: string, cancelledAt: string) => string[] repository: AutomationDefinitionCommandRepository tryBlock: (input: BlockAutomationInput) => boolean } @@ -68,6 +74,7 @@ export function createAutomationDefinitionCommandStore( SET status = 'cancelled', lease_owner = NULL, lease_expires_at = NULL, finished_at = ?, error_code = NULL, error_summary = NULL WHERE automation_id = ? AND status = 'queued' AND run_id IS NULL + RETURNING id `) const blockAutomation = database.prepare(` UPDATE automations @@ -93,8 +100,8 @@ export function createAutomationDefinitionCommandStore( ORDER BY id `) - const cancelQueuedOccurrences = (automationId: string, cancelledAt: string): void => { - cancelQueued.run(cancelledAt, automationId) + const cancelQueuedOccurrences = (automationId: string, cancelledAt: string): string[] => { + return (cancelQueued.all(cancelledAt, automationId) as { id: string }[]).map(row => row.id) } const tryBlock = (input: BlockAutomationInput): boolean => { return Number(blockAutomation.run( @@ -108,9 +115,9 @@ export function createAutomationDefinitionCommandStore( rows: AutomationRow[], reason: Automation['blockedReason'] & string, blockedAt: string, - ): Automation[] => { + ): AutomationDefinitionCommit[] => { return withTransaction(database, () => { - const blocked: Automation[] = [] + const blocked: AutomationDefinitionCommit[] = [] for (const row of rows) { if (!tryBlock({ automationId: row.id, @@ -120,8 +127,8 @@ export function createAutomationDefinitionCommandStore( })) { continue } - cancelQueuedOccurrences(row.id, blockedAt) - blocked.push(records.requireById(row.id)) + const cancelledOccurrenceIds = cancelQueuedOccurrences(row.id, blockedAt) + blocked.push({ automation: records.requireById(row.id), committed: true, cancelledOccurrenceIds }) } return blocked }) @@ -134,8 +141,8 @@ export function createAutomationDefinitionCommandStore( return withTransaction(database, () => { if (!tryBlock(input)) return null - cancelQueuedOccurrences(input.automationId, input.blockedAt) - return records.requireById(input.automationId) + const cancelledOccurrenceIds = cancelQueuedOccurrences(input.automationId, input.blockedAt) + return { automation: records.requireById(input.automationId), committed: true, cancelledOccurrenceIds } }) }, blockActiveByPinnedModel(input) { @@ -160,18 +167,18 @@ export function createAutomationDefinitionCommandStore( return withTransaction(database, () => { const replay = mutations.repository.replayAutomationMutation(mutation) if (replay) - return replay + return { automation: replay, committed: false, cancelledOccurrenceIds: [] } persistAutomation(insertAutomation, automation) const stored = records.requireById(automation.id) mutations.save(automation.id, mutation, stored) - return stored + return { automation: stored, committed: true, cancelledOccurrenceIds: [] } }) }, replace(input, mutation) { return withTransaction(database, () => { const replay = mutations.repository.replayAutomationMutation(mutation) if (replay) - return replay + return { automation: replay, committed: false, cancelledOccurrenceIds: [] } const existing = records.findAnyRow(input.automation.id) if (!existing || existing.deleted_at) throw new AutomationRepositoryError('not_found') @@ -202,15 +209,15 @@ export function createAutomationDefinitionCommandStore( ).changes) !== 1) { throw new AutomationRepositoryError('conflict') } - if (input.cancelQueued) { - cancelQueuedOccurrences( - input.automation.id, - input.automation.updatedAt, - ) - } + const cancelledOccurrenceIds = input.cancelQueued + ? cancelQueuedOccurrences( + input.automation.id, + input.automation.updatedAt, + ) + : [] const stored = records.requireById(input.automation.id) mutations.save(stored.id, mutation, stored) - return stored + return { automation: stored, committed: true, cancelledOccurrenceIds } }) }, }, diff --git a/apps/buddy/service/src/storage/automationOccurrenceCommandRepository.ts b/apps/buddy/service/src/storage/automationOccurrenceCommandRepository.ts index fd5ef05d..44840fee 100644 --- a/apps/buddy/service/src/storage/automationOccurrenceCommandRepository.ts +++ b/apps/buddy/service/src/storage/automationOccurrenceCommandRepository.ts @@ -4,6 +4,7 @@ import type { AutomationOccurrenceRecord, AutomationOccurrenceRow, } from './automationOccurrenceRecord' +import { toAutomationOccurrenceRecord } from './automationOccurrenceRecord' import { withTransaction } from './database' interface AutomationOccurrenceRecordReader { @@ -29,7 +30,7 @@ export interface AutomationOccurrenceCommandRepository { now: string owner: string }) => AutomationOccurrenceRecord[] - markOccurrenceDeleted: (id: string, deletedAt: string) => boolean + markOccurrenceDeleted: (id: string, deletedAt: string) => { occurrence: AutomationOccurrenceRecord, previousStatus: AutomationOccurrenceRecord['status'] } | null } export interface AutomationOccurrenceCommandStore { @@ -72,6 +73,7 @@ export function createAutomationOccurrenceCommandStore( lease_owner = NULL, lease_expires_at = NULL WHERE id = ? AND deleted_at IS NULL + RETURNING * `) const tryFinishQueued = (input: FinishQueuedAutomationOccurrenceInput): boolean => { @@ -90,9 +92,11 @@ export function createAutomationOccurrenceCommandStore( return { repository: { finishQueued(input) { - if (!tryFinishQueued(input)) - return null - return occurrenceRecords.requireById(input.id) + return withTransaction(database, () => { + if (!tryFinishQueued(input)) + return null + return occurrenceRecords.requireById(input.id) + }) }, leaseQueued(input) { return withTransaction(database, () => { @@ -116,7 +120,13 @@ export function createAutomationOccurrenceCommandStore( }) }, markOccurrenceDeleted(id, deletedAt) { - return Number(markOccurrenceDeleted.run(deletedAt, deletedAt, id).changes) === 1 + return withTransaction(database, () => { + const previous = database.prepare('SELECT status FROM automation_occurrences WHERE id = ? AND deleted_at IS NULL').get(id) as { status: AutomationOccurrenceRecord['status'] } | undefined + if (!previous) + return null + const row = markOccurrenceDeleted.get(deletedAt, deletedAt, id) as unknown as AutomationOccurrenceRow + return { occurrence: toAutomationOccurrenceRecord(row), previousStatus: previous.status } + }) }, }, tryFinishQueued, diff --git a/apps/buddy/service/src/storage/automationOccurrenceIndexRepository.ts b/apps/buddy/service/src/storage/automationOccurrenceIndexRepository.ts index f9dab04b..c51e0afb 100644 --- a/apps/buddy/service/src/storage/automationOccurrenceIndexRepository.ts +++ b/apps/buddy/service/src/storage/automationOccurrenceIndexRepository.ts @@ -15,6 +15,9 @@ export interface AutomationOccurrenceIndexRepository { conversationId: string, ) => AutomationOccurrenceRecord | null findOccurrenceById: (id: string) => AutomationOccurrenceRecord | null + findOccurrenceForDeletion: (id: string) => AutomationOccurrenceRecord | null + findOccurrenceDeletionByConversation: (conversationId: string) => AutomationOccurrenceRecord | null + listPendingDeletions: () => AutomationOccurrenceRecord[] listHistory: (input: { automationId?: string | null before?: AutomationCursor | null @@ -69,6 +72,19 @@ export function createAutomationOccurrenceIndexStore( findOccurrenceById(id) { return toOptionalOccurrence(findOccurrence.get(id)) }, + findOccurrenceForDeletion(id) { + return toOptionalOccurrence(database.prepare('SELECT * FROM automation_occurrences WHERE id = ?').get(id)) + }, + findOccurrenceDeletionByConversation(conversationId) { + return toOptionalOccurrence(database.prepare('SELECT * FROM automation_occurrences WHERE conversation_id = ?').get(conversationId)) + }, + listPendingDeletions() { + return (database.prepare(`SELECT o.* FROM automation_occurrences o + INNER JOIN conversations c ON c.id = o.conversation_id + LEFT JOIN runs r ON r.id = o.run_id + WHERE o.deleted_at IS NOT NULL AND (c.deleted_at IS NULL OR r.status IN ('queued', 'running')) + ORDER BY o.deleted_at, o.id`).all() as unknown as AutomationOccurrenceRow[]).map(toAutomationOccurrenceRecord) + }, listHistory(input) { const clauses: string[] = [ 'automation_occurrences.deleted_at IS NULL', diff --git a/apps/buddy/service/src/storage/automationOccurrenceTransactionRepository.ts b/apps/buddy/service/src/storage/automationOccurrenceTransactionRepository.ts index 25a380f8..4b6a08e9 100644 --- a/apps/buddy/service/src/storage/automationOccurrenceTransactionRepository.ts +++ b/apps/buddy/service/src/storage/automationOccurrenceTransactionRepository.ts @@ -42,7 +42,7 @@ export interface AutomationOccurrenceTransactionRepository { id: string queuedAt: string scheduledFor: string - }, mutation: AutomationMutationIdentity) => AutomationRunNowResult + }, mutation: AutomationMutationIdentity) => { result: AutomationRunNowResult, committed: boolean } finishQueuedAndBlock: (input: { automationId: string expectedRevision: number @@ -53,6 +53,7 @@ export interface AutomationOccurrenceTransactionRepository { }) => { automation: Automation | null occurrence: AutomationOccurrenceRecord + cancelledOccurrenceIds: readonly string[] } | null settleScheduled: (input: { automationId: string @@ -230,7 +231,7 @@ export function createAutomationOccurrenceTransactionRepository( return withTransaction(options.database, () => { const replay = options.mutations.repository.replayRunNowMutation(mutation) if (replay) - return replay + return { result: replay, committed: false } const row = options.definitions.findAnyRow(input.automationId) if (!row || row.deleted_at) throw new AutomationRepositoryError('not_found') @@ -241,7 +242,7 @@ export function createAutomationOccurrenceTransactionRepository( outcome: 'already_running', }) options.mutations.save(row.id, mutation, result) - return result + return { result, committed: false } } if (row.revision !== input.expectedRevision) throw new AutomationRepositoryError('conflict') @@ -267,7 +268,7 @@ export function createAutomationOccurrenceTransactionRepository( outcome: 'started', }) options.mutations.save(row.id, mutation, result) - return result + return { result, committed: true } }) }, finishQueuedAndBlock(input) { @@ -288,17 +289,18 @@ export function createAutomationOccurrenceTransactionRepository( expectedRevision: input.expectedRevision, reason: input.reason, }) - if (blocked) { - options.definitionCommands.cancelQueuedOccurrences( - input.automationId, - input.finishedAt, - ) - } + const cancelledOccurrenceIds = blocked + ? options.definitionCommands.cancelQueuedOccurrences( + input.automationId, + input.finishedAt, + ) + : [] return { automation: blocked ? options.definitions.requireById(input.automationId) : null, occurrence: options.occurrences.requireById(input.id), + cancelledOccurrenceIds, } }) }, diff --git a/apps/buddy/service/src/storage/automationTurnRepository.ts b/apps/buddy/service/src/storage/automationTurnRepository.ts index 70d54428..a4b417d5 100644 --- a/apps/buddy/service/src/storage/automationTurnRepository.ts +++ b/apps/buddy/service/src/storage/automationTurnRepository.ts @@ -36,6 +36,7 @@ export interface BindAutomationTurnInput { export interface BoundAutomationTurn { conversation: ConversationRecord kind: 'bound' + lastRunChanged: boolean occurrence: AutomationOccurrenceRecord run: RunRecord } @@ -122,7 +123,7 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati AND lease_owner = ? AND lease_expires_at >= ? `) const updateLastRun = database.prepare(` - UPDATE automations SET last_run_at = ?, updated_at = ? WHERE id = ? + UPDATE automations SET last_run_at = ?, updated_at = ? WHERE id = ? AND last_run_at IS NOT ? `) const findNonTerminalRun = database.prepare(` SELECT 1 @@ -237,13 +238,14 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati ).changes) !== 1) { throw new AutomationTurnBindingError() } - updateLastRun.run(input.boundAt, input.boundAt, occurrence.automationId) + const lastRunChanged = Number(updateLastRun.run(input.boundAt, input.boundAt, occurrence.automationId, input.boundAt).changes) === 1 return { conversation: requireConversationRecord( findConversation.get(input.conversationId), input.conversationId, ), kind: 'bound', + lastRunChanged, occurrence: requireOccurrenceRecord(occurrence.id), run: requireRunRecord(findRun.get(input.runId), input.runId), } diff --git a/apps/buddy/service/src/storage/chatQueueRepository.ts b/apps/buddy/service/src/storage/chatQueueRepository.ts index 98d19437..6de1bcd0 100644 --- a/apps/buddy/service/src/storage/chatQueueRepository.ts +++ b/apps/buddy/service/src/storage/chatQueueRepository.ts @@ -84,6 +84,21 @@ export function createChatQueueRepository(database: DatabaseSync) { }) } }) }, + continuationScopes(): LocalChatQueueScope[] { + return database.prepare(`SELECT DISTINCT q.conversation_id AS conversationId, q.branch_id AS branchId + FROM chat_queue q JOIN conversations c ON c.id = q.conversation_id + WHERE q.state IN ('waiting', 'paused') AND c.deleted_at IS NULL AND c.active_branch_id = q.branch_id`).all() as unknown as LocalChatQueueScope[] + }, + continuationScope(conversationId: string): LocalChatQueueScope | null { + return database.prepare(`SELECT id AS conversationId, active_branch_id AS branchId FROM conversations c + WHERE id = ? AND deleted_at IS NULL AND EXISTS ( + SELECT 1 FROM chat_queue q WHERE q.conversation_id = c.id AND q.branch_id = c.active_branch_id AND q.state IN ('waiting', 'paused') + )`).get(conversationId) as LocalChatQueueScope | undefined ?? null + }, + latestRun(scope: LocalChatQueueScope) { + return database.prepare('SELECT id, status FROM runs WHERE conversation_id = ? ORDER BY rowid DESC LIMIT 1') + .get(scope.conversationId) as { id: string, status: string } | undefined + }, pending(target: LocalChatQueueTarget) { assertScope(target) const row = find(target.id) @@ -100,9 +115,8 @@ export function createChatQueueRepository(database: DatabaseSync) { }, pause(conversationId?: string) { if (conversationId) - database.prepare('UPDATE chat_queue SET state = \'paused\' WHERE conversation_id = ? AND state = \'waiting\'').run(conversationId) - else - database.prepare('UPDATE chat_queue SET state = \'paused\' WHERE state = \'waiting\'').run() + return Number(database.prepare('UPDATE chat_queue SET state = \'paused\' WHERE conversation_id = ? AND state = \'waiting\'').run(conversationId).changes) + return Number(database.prepare('UPDATE chat_queue SET state = \'paused\' WHERE state = \'waiting\'').run().changes) }, commitInRun(input: PrepareTurnRequestInput, runId: string) { return withTransaction(database, () => { diff --git a/apps/buddy/service/src/storage/conversationDeletionRepository.ts b/apps/buddy/service/src/storage/conversationDeletionRepository.ts new file mode 100644 index 00000000..dbbbcf88 --- /dev/null +++ b/apps/buddy/service/src/storage/conversationDeletionRepository.ts @@ -0,0 +1,35 @@ +import type { DatabaseSync } from 'node:sqlite' +import { withTransaction } from './database' + +export interface ConversationDeletionCommit { + conversationId: string + deletedAt: string + tombstoned: boolean + revokedGrantIds: string[] +} + +export type ConversationDeletionRepository = ReturnType + +export function createConversationDeletionRepository(database: DatabaseSync) { + return { + commit(conversationId: string, now: string): ConversationDeletionCommit | null { + return withTransaction(database, () => { + const conversation = database.prepare('SELECT deleted_at FROM conversations WHERE id = ?').get(conversationId) as { deleted_at: string | null } | undefined + if (!conversation) + return null + const deletedAt = conversation.deleted_at ?? now + const grants = database.prepare('SELECT id FROM conversation_directory_grants WHERE conversation_id = ? AND revoked_at IS NULL').all(conversationId) as { id: string }[] + if (conversation.deleted_at === null) + database.prepare('UPDATE conversations SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL').run(deletedAt, deletedAt, conversationId) + database.prepare('UPDATE conversation_directory_grants SET revoked_at = ? WHERE conversation_id = ? AND revoked_at IS NULL').run(deletedAt, conversationId) + return { conversationId, deletedAt, tombstoned: conversation.deleted_at === null, revokedGrantIds: grants.map(grant => grant.id) } + }) + }, + pending(): string[] { + return (database.prepare(`SELECT c.id FROM conversations c WHERE c.deleted_at IS NOT NULL AND ( + EXISTS (SELECT 1 FROM conversation_directory_grants g WHERE g.conversation_id = c.id AND g.revoked_at IS NULL) + OR EXISTS (SELECT 1 FROM runs r WHERE r.conversation_id = c.id AND r.status IN ('queued', 'running')) + ) ORDER BY c.id`).all() as { id: string }[]).map(row => row.id) + }, + } +} diff --git a/apps/buddy/service/src/storage/conversationDirectoryGrantRepository.ts b/apps/buddy/service/src/storage/conversationDirectoryGrantRepository.ts index e4086a84..6c18a30b 100644 --- a/apps/buddy/service/src/storage/conversationDirectoryGrantRepository.ts +++ b/apps/buddy/service/src/storage/conversationDirectoryGrantRepository.ts @@ -69,6 +69,8 @@ export function createConversationDirectoryGrantRepository( return { grant(input) { return withTransaction(database, () => { + if (!database.prepare('SELECT id FROM conversations WHERE id = ? AND deleted_at IS NULL AND space_id IS NULL').get(input.conversationId)) + throw new ConversationDirectoryGrantOwnerError() const current = listActive(input.conversationId) const covering = [...current] .sort((left, right) => right.canonicalRoot.length - left.canonicalRoot.length) @@ -131,3 +133,12 @@ function containsDirectory(root: string, candidate: string): boolean { function impassableDirectoryGrant(): never { throw new Error('Lexora Buddy conversation directory grant could not be persisted') } + +class ConversationDirectoryGrantOwnerError extends Error { + readonly code = 'DIRECTORY_GRANT_OWNER_INVALID' + + constructor() { + super('Lexora Buddy directory grant owner is unavailable') + this.name = 'ConversationDirectoryGrantOwnerError' + } +} diff --git a/apps/buddy/service/src/storage/conversationRepository.ts b/apps/buddy/service/src/storage/conversationRepository.ts index 8cbf9de8..b943dc4b 100644 --- a/apps/buddy/service/src/storage/conversationRepository.ts +++ b/apps/buddy/service/src/storage/conversationRepository.ts @@ -32,7 +32,12 @@ export interface CreateConversationInput { export interface RenameConversationInput { id: string title: string - updatedAt: string +} + +export interface ConversationTitleState { + title: string | null + source: 'manual' | 'fallback' | 'generated' + revision: number } export interface SetConversationPermissionSettingsInput { @@ -56,6 +61,8 @@ export interface ConversationRepository isDeleted: (id: string) => boolean markDeleted: (id: string, deletedAt: string) => boolean rename: (input: RenameConversationInput) => ConversationRecord + getTitleState: (id: string) => ConversationTitleState | null + renameGenerated: (input: RenameConversationInput & { expectedRevision: number }) => ConversationRecord | null setPermissionSettings: ( input: SetConversationPermissionSettingsInput, ) => ConversationRecord | null @@ -74,9 +81,14 @@ export function createConversationRepository(database: DatabaseSync): Conversati `) const renameConversation = database.prepare(` UPDATE conversations - SET title = ?, updated_at = ? + SET title = ?, title_source = 'manual', title_revision = title_revision + 1 WHERE id = ? AND deleted_at IS NULL `) + const findTitle = database.prepare('SELECT title, title_source AS source, title_revision AS revision FROM conversations WHERE id = ? AND deleted_at IS NULL') + const renameGenerated = database.prepare(` + UPDATE conversations SET title = ?, title_source = 'generated', title_revision = title_revision + 1 + WHERE id = ? AND title_revision = ? AND title_source != 'manual' AND deleted_at IS NULL + `) const setPermissionSettings = database.prepare(` UPDATE conversations SET approval_policy = ?, execution_profile = ?, updated_at = ? @@ -155,13 +167,21 @@ export function createConversationRepository(database: DatabaseSync): Conversati return Number(markDeleted.run(deletedAt, deletedAt, id).changes) === 1 }, rename(input) { - if (Number(renameConversation.run(input.title, input.updatedAt, input.id).changes) !== 1) + if (Number(renameConversation.run(input.title, input.id).changes) !== 1) throw new ConversationRepositoryError('cannot be renamed') return requireConversationRecord( findConversation.get(input.id), input.id, ) }, + getTitleState(id) { + return findTitle.get(id) as unknown as ConversationTitleState | undefined ?? null + }, + renameGenerated(input) { + if (Number(renameGenerated.run(input.title, input.id, input.expectedRevision).changes) !== 1) + return null + return requireConversationRecord(findConversation.get(input.id), input.id) + }, setPermissionSettings(input) { return withTransaction(database, () => { const current = findConversation.get(input.id) as ConversationRow | undefined diff --git a/apps/buddy/service/src/storage/conversationTreeRepository.ts b/apps/buddy/service/src/storage/conversationTreeRepository.ts index 9f04c3d3..07fa5f10 100644 --- a/apps/buddy/service/src/storage/conversationTreeRepository.ts +++ b/apps/buddy/service/src/storage/conversationTreeRepository.ts @@ -6,8 +6,17 @@ import type { RunRow } from './runRecord' import { toRunRecord } from './runRecord' export function createConversationTreeRepository(database: DatabaseSync) { - const binding = database.prepare('SELECT session_file AS sessionFile, root_entry_id AS rootEntryId FROM conversation_pi_trees WHERE conversation_id = ?') const bind = database.prepare('INSERT INTO conversation_pi_trees (conversation_id, session_file, root_entry_id) VALUES (?, ?, ?) ON CONFLICT(conversation_id) DO UPDATE SET session_file = excluded.session_file, root_entry_id = excluded.root_entry_id') + return { + ...createConversationTreeReader(database), + bind: (conversationId: string, sessionFile: string, rootEntryId: string) => { + bind.run(conversationId, sessionFile, rootEntryId) + }, + } +} + +export function createConversationTreeReader(database: DatabaseSync) { + const binding = database.prepare('SELECT session_file AS sessionFile, root_entry_id AS rootEntryId FROM conversation_pi_trees WHERE conversation_id = ?') const source = database.prepare('SELECT source_run_id AS sourceRunId, position FROM run_tree_sources WHERE run_id = ?') const runs = database.prepare('SELECT * FROM runs WHERE conversation_id = ? ORDER BY started_at, id') const messages = database.prepare(`SELECT id, conversation_id AS conversationId, @@ -48,9 +57,6 @@ export function createConversationTreeRepository(database: DatabaseSync) { .map(({ content_json, ...message }) => ({ ...message, content: JSON.parse(content_json) })), listToolCounts: (conversationId: string) => new Map((tools.all(conversationId) as { runId: string, count: number }[]).map(row => [row.runId, row.count])), findBinding: (conversationId: string) => binding.get(conversationId) as { sessionFile: string, rootEntryId: string } | undefined, - bind: (conversationId: string, sessionFile: string, rootEntryId: string) => { - bind.run(conversationId, sessionFile, rootEntryId) - }, findSource: (runId: string) => source.get(runId) as { sourceRunId: string, position: 'before' | 'after' } | undefined, listRuns: (conversationId: string) => (runs.all(conversationId) as unknown as RunRow[]).map(toRunRecord), } diff --git a/apps/buddy/service/src/storage/database.ts b/apps/buddy/service/src/storage/database.ts index bd68b170..bc7ab893 100644 --- a/apps/buddy/service/src/storage/database.ts +++ b/apps/buddy/service/src/storage/database.ts @@ -17,6 +17,7 @@ export interface OpenBuddyDatabaseOptions { } const BUDDY_CURRENT_SCHEMA_COLUMNS = { + conversations: ['title_source', 'title_revision'], connector_tool_catalogs: ['connector_id', 'tools_json', 'updated_at'], provider_states: ['provider_id', 'request_headers_json'], builtin_provider_configs: ['id', 'builtin_provider_id', 'display_name'], diff --git a/apps/buddy/service/src/storage/migrations/v21Title.ts b/apps/buddy/service/src/storage/migrations/v21Title.ts new file mode 100644 index 00000000..bd41c886 --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v21Title.ts @@ -0,0 +1,5 @@ +export const BUDDY_V21_TITLE_SCHEMA_SQL = ` + ALTER TABLE conversations ADD COLUMN title_source TEXT NOT NULL DEFAULT 'manual' + CHECK (title_source IN ('manual', 'fallback', 'generated')); + ALTER TABLE conversations ADD COLUMN title_revision INTEGER NOT NULL DEFAULT 0; +` diff --git a/apps/buddy/service/src/storage/runRepository.ts b/apps/buddy/service/src/storage/runRepository.ts index 6530a032..cf67a75e 100644 --- a/apps/buddy/service/src/storage/runRepository.ts +++ b/apps/buddy/service/src/storage/runRepository.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from 'node:sqlite' import type { RunRecord, RunRow, RunStatus } from './runRecord' +import { withTransaction } from './database' import { requireRunRecord, toRunRecord } from './runRecord' export interface CreateRunInput extends Omit< @@ -20,6 +21,7 @@ export interface RunRepository { branchId: string, piSessionFile: string, ) => number + clearSessionBindingsWithReceipt: (conversationId: string, branchId: string, piSessionFile: string) => string[] create: (input: CreateRunInput) => RunRecord findById: (id: string) => RunRecord | null findLatestForBranch: (conversationId: string, branchId: string) => RunRecord | null @@ -76,6 +78,7 @@ export function createRunRepository(database: DatabaseSync): RunRepository { UPDATE runs SET status = 'running', started_at = ?, completed_at = NULL, error_code = NULL WHERE id = ? AND status = 'queued' + AND EXISTS (SELECT 1 FROM conversations WHERE conversations.id = runs.conversation_id AND deleted_at IS NULL) `) const reconcileTerminal = database.prepare(` UPDATE runs SET status = ?, completed_at = ?, error_code = ? @@ -88,18 +91,20 @@ export function createRunRepository(database: DatabaseSync): RunRepository { UPDATE runs SET pi_session_file = NULL WHERE conversation_id = ? AND branch_id = ? AND pi_session_file = ? `) + const clearBindings = (conversationId: string, branchId: string, piSessionFile: string): string[] => withTransaction(database, () => { + const affected = database.prepare('SELECT id FROM runs WHERE conversation_id = ? AND branch_id = ? AND pi_session_file = ?').all(conversationId, branchId, piSessionFile) as { id: string }[] + clearSessionBindings.run(conversationId, branchId, piSessionFile) + return affected.map(run => run.id) + }) return { bindSession(id, piSessionFile) { return Number(bindSession.run(piSessionFile, id).changes) === 1 }, clearSessionBindings(conversationId, branchId, piSessionFile) { - return Number(clearSessionBindings.run( - conversationId, - branchId, - piSessionFile, - ).changes) + return clearBindings(conversationId, branchId, piSessionFile).length }, + clearSessionBindingsWithReceipt: clearBindings, create(input) { insert.run( input.id, diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts index 88d3233f..2a4e0d20 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -20,6 +20,7 @@ import { BUDDY_V18_CONNECTORS_SCHEMA_SQL } from './migrations/v18Connectors' import { BUDDY_V19_LOCAL_RESOURCES_SCHEMA_SQL } from './migrations/v19LocalResources' import { BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL } from './migrations/v20TaskDrafts' +import { BUDDY_V21_TITLE_SCHEMA_SQL } from './migrations/v21Title' export interface BuddySchemaMigration { foreignKeys?: 'off' @@ -27,7 +28,7 @@ export interface BuddySchemaMigration { version: number } -export const BUDDY_SCHEMA_VERSION = 20 as const +export const BUDDY_SCHEMA_VERSION = 21 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -50,4 +51,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V18_CONNECTORS_SCHEMA_SQL, version: 18 }, { sql: BUDDY_V19_LOCAL_RESOURCES_SCHEMA_SQL, version: 19 }, { foreignKeys: 'off', sql: BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL, version: 20 }, + { sql: BUDDY_V21_TITLE_SCHEMA_SQL, version: 21 }, ] diff --git a/apps/buddy/service/src/storage/taskMarkRepository.ts b/apps/buddy/service/src/storage/taskMarkRepository.ts index 7c5720e3..d2361888 100644 --- a/apps/buddy/service/src/storage/taskMarkRepository.ts +++ b/apps/buddy/service/src/storage/taskMarkRepository.ts @@ -89,9 +89,21 @@ export function createTaskMarkRepository(database: DatabaseSync) { return toState(row) } + function deleteWithReceipt(id: string) { + return withTransaction(database, () => { + requireMark(id) + const affected = database.prepare('SELECT conversation_id FROM task_attention WHERE mark_id = ?').all(id) as { conversation_id: string }[] + const deleted = Number(remove.run(id).changes) === 1 + return { deleted, conversationIds: affected.map(row => row.conversation_id) } + }) + } + return { list, getState, + allStates(): LocalTaskMarkState[] { + return (database.prepare(STATE_QUERY).all() as unknown as StateRow[]).map(toState) + }, states(conversationIds: readonly string[]): LocalTaskMarkState[] { if (conversationIds.length === 0) return [] @@ -110,9 +122,9 @@ export function createTaskMarkRepository(database: DatabaseSync) { return requireMark(id) }, delete(id: string): boolean { - requireMark(id) - return Number(remove.run(id).changes) === 1 + return deleteWithReceipt(id).deleted }, + deleteWithReceipt, assign(conversationId: string, markId: string | null): LocalTaskMarkState { return withTransaction(database, () => { getState(conversationId) diff --git a/apps/buddy/service/src/storage/turnRequestRepository.ts b/apps/buddy/service/src/storage/turnRequestRepository.ts index 0dfeae39..5771dcb6 100644 --- a/apps/buddy/service/src/storage/turnRequestRepository.ts +++ b/apps/buddy/service/src/storage/turnRequestRepository.ts @@ -2,6 +2,7 @@ import type { DatabaseSync } from 'node:sqlite' import type { BuddyServiceTier, BuddyThinkingLevel } from '../../../shared/conversation/modelSelection' import type { BuddyApprovalPolicy } from '../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../shared/permissions/executionProfile' +import type { ComposerDraftCommitReceipt } from './commitComposerDraft' import type { RunInputContextItem } from './runInputRepository' import { isExecutionProfileWithin } from '../../../shared/permissions/executionProfile' import { createComposerDraftCommitter } from './commitComposerDraft' @@ -70,8 +71,21 @@ export interface TurnRequestRecord { requestFingerprint: string requestId: string runId: string + committedFacts?: readonly TurnCommitFact[] } +export type TurnCommitFact + = { kind: 'task.created' } + | { kind: 'branch.created', parentBranchId: string | null } + | { kind: 'task.branch_activated' } + | { kind: 'task.model_changed' } + | { kind: 'message.created', messageId: string } + | { kind: 'attachments.bound', messageId: string, attachmentIds: readonly string[] } + | { kind: 'run.queued' } + | { kind: 'request.retried', previousRunId: string } + | { kind: 'draft.consumed', receipt: ComposerDraftCommitReceipt } + | { kind: 'queue.dispatched', queueId: string } + export interface RetryInterruptedTurnRequestInput { createdAt: string requestId: string @@ -118,6 +132,7 @@ interface ConversationBindingRow { execution_profile: BuddyExecutionProfile space_id: string | null origin: 'automation' | 'interactive' + model_selection_json: string | null } interface RetryRunRow { @@ -165,14 +180,14 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest `) const findRequest = database.prepare('SELECT * FROM turn_requests WHERE request_id = ?') const findConversation = database.prepare(` - SELECT space_id, active_branch_id, approval_policy, execution_profile, origin, deleted_at + SELECT space_id, active_branch_id, approval_policy, execution_profile, origin, deleted_at, model_selection_json FROM conversations WHERE id = ? `) const insertConversation = database.prepare(` INSERT INTO conversations ( id, space_id, title, active_branch_id, created_at, updated_at, - approval_policy, execution_profile, model_selection_json - ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL) + approval_policy, execution_profile, model_selection_json, title_source + ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, 'fallback') `) const insertBranch = database.prepare(` INSERT INTO conversation_branches ( @@ -397,6 +412,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest requestFingerprint: input.requestFingerprint, requestId: input.requestId, runId: input.runId, + committedFacts: createTurnFacts({ branchCreated: true, parentBranchId: input.forkedFromMessageId ? input.parentBranchId : null, messageId: input.userMessageId, attachmentIds: input.attachmentBindings.map(binding => binding.id), draftReceipt, modelChanged: !sameModelSelection(conversation.model_selection_json, stringifyModelSelection(input)) }), } }) }, @@ -536,6 +552,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest requestFingerprint: input.requestFingerprint, requestId: input.requestId, runId: input.runId, + committedFacts: createTurnFacts({ taskCreated: !conversation, branchCreated: !conversation || !!input.followup, parentBranchId: input.followup?.parentBranchId ?? null, messageId: input.userMessageId, attachmentIds: input.attachmentBindings.map(binding => binding.id), draftReceipt: input.queuedMessageId ? null : draftReceipt, queueId: input.queuedMessageId, modelChanged: !sameModelSelection(conversation?.model_selection_json ?? null, stringifyModelSelection(input)) }), } }) }, @@ -615,6 +632,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest requestFingerprint: input.requestFingerprint, requestId: input.requestId, runId: input.runId, + committedFacts: createTurnFacts({ branchCreated: true, parentBranchId: input.parentBranchId, modelChanged: !sameModelSelection(conversation.model_selection_json, modelSelection.model_selection_json) }), } }) }, @@ -660,6 +678,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest return { ...toRecord(request, true), runId: input.runId, + committedFacts: [{ kind: 'request.retried', previousRunId: request.run_id }, { kind: 'run.queued' }], } }) }, @@ -713,6 +732,27 @@ function stringifyModelSelection(input: Omit): }) } +function sameModelSelection(left: string | null, right: string): boolean { + if (!left) + return false + const previous = JSON.parse(left) + const next = JSON.parse(right) + return ['providerId', 'modelId', 'reasoning', 'serviceTier'].every(key => previous[key] === next[key]) +} + +function createTurnFacts(input: { taskCreated?: boolean, branchCreated?: boolean, parentBranchId?: string | null, messageId?: string, attachmentIds?: readonly string[], draftReceipt?: ComposerDraftCommitReceipt | null, queueId?: string, modelChanged?: boolean }): TurnCommitFact[] { + return [ + ...(input.taskCreated ? [{ kind: 'task.created' as const }] : []), + ...(input.branchCreated ? [{ kind: 'branch.created' as const, parentBranchId: input.parentBranchId ?? null }, { kind: 'task.branch_activated' as const }] : []), + ...(input.modelChanged ? [{ kind: 'task.model_changed' as const }] : []), + ...(input.messageId ? [{ kind: 'message.created' as const, messageId: input.messageId }] : []), + ...(input.messageId && input.attachmentIds?.length ? [{ kind: 'attachments.bound' as const, messageId: input.messageId, attachmentIds: input.attachmentIds }] : []), + { kind: 'run.queued' }, + ...(input.draftReceipt ? [{ kind: 'draft.consumed' as const, receipt: input.draftReceipt }] : []), + ...(input.queueId ? [{ kind: 'queue.dispatched' as const, queueId: input.queueId }] : []), + ] +} + function bindAttachments( input: Omit, bindDraftAttachment: ReturnType, diff --git a/apps/buddy/service/src/system/__tests__/SystemCapabilityService.spec.ts b/apps/buddy/service/src/system/__tests__/SystemCapabilityService.spec.ts index e0ba8fc1..c92ec989 100644 --- a/apps/buddy/service/src/system/__tests__/SystemCapabilityService.spec.ts +++ b/apps/buddy/service/src/system/__tests__/SystemCapabilityService.spec.ts @@ -1,4 +1,5 @@ import type { + SystemActionChange, SystemActionRequest, SystemHostPort, SystemTarget, @@ -172,6 +173,47 @@ describe('systemCapabilityService', () => { expect(host.execute).not.toHaveBeenCalled() }) + it('keeps execution confirmation when the post-action observation fails', async () => { + const host = createHost() + const service = new SystemCapabilityService({ host }) + const facts: SystemActionChange[] = [] + service.onDidChange(change => facts.push(change)) + await service.prepareAction('tool-1', terminateRequest, new AbortController().signal) + vi.mocked(host.readTarget).mockResolvedValueOnce(processTarget).mockRejectedValueOnce(new Error('private-postcondition')) + await expect(service.act('tool-1', terminateRequest, new AbortController().signal)).rejects.toThrow('private-postcondition') + expect(facts.map(({ phase, effect }) => ({ phase, effect }))).toEqual([ + { phase: 'dispatched', effect: 'unknown' }, + { phase: 'confirmed', effect: 'confirmed' }, + { phase: 'failed', effect: 'confirmed' }, + ]) + expect(JSON.stringify(facts)).not.toContain('private-postcondition') + await service.dispose() + }) + + it('waits for admitted execution and records an unknown result after cancellation', async () => { + const host = createHost() + const execution = Promise.withResolvers() + vi.mocked(host.execute).mockReturnValue(execution.promise) + const service = new SystemCapabilityService({ host }) + const facts: SystemActionChange[] = [] + service.onDidChange(change => facts.push(change)) + await service.prepareAction('tool-1', terminateRequest, new AbortController().signal) + const acting = service.act('tool-1', terminateRequest, new AbortController().signal) + const rejected = expect(acting).rejects.toThrow('execution unknown') + await vi.waitFor(() => expect(host.execute).toHaveBeenCalledOnce()) + let disposed = false + const stopping = service.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + execution.reject(new Error('execution unknown')) + await rejected + await stopping + expect(facts.at(-1)).toMatchObject({ phase: 'failed', effect: 'unknown', cancelled: true }) + expect(service.snapshot.pending).toBe(0) + }) + it('keeps raw systemd identity internal to the host adapter', async () => { const rawUnit = 'app-fixture\\x2dclient@autostart.service' const displayId = 'app-fixture-client@autostart.service' diff --git a/apps/buddy/service/src/system/observeSystemDiagnostics.ts b/apps/buddy/service/src/system/observeSystemDiagnostics.ts new file mode 100644 index 00000000..03cff7a0 --- /dev/null +++ b/apps/buddy/service/src/system/observeSystemDiagnostics.ts @@ -0,0 +1,26 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { SystemCapabilityService } from './systemCapability' + +export function observeSystemDiagnostics(service: SystemCapabilityService, report: ApplicationDiagnosticReporter) { + const actions = service.onDidChange(change => report({ + event: `system.action.${change.phase}_${change.effect.replaceAll('-', '_')}${change.cancelled ? '_after_cancel' : ''}`, + component: 'runtime.system', + level: change.phase === 'failed' || change.verified === false ? 'warn' : 'info', + operationId: change.operationId, + revision: change.revision, + ...(change.errorCode ? { errorCode: change.errorCode } : {}), + })) + const preparations = service.onDidChangePreparation(change => report({ + event: `system.preparation.${change.reason ?? change.phase}`, + component: 'runtime.system', + level: 'info', + operationId: change.preparationId, + revision: change.revision, + })) + return { + dispose() { + actions.dispose() + preparations.dispose() + }, + } +} diff --git a/apps/buddy/service/src/system/systemCapability.ts b/apps/buddy/service/src/system/systemCapability.ts index 0a68dedd..4dc0b188 100644 --- a/apps/buddy/service/src/system/systemCapability.ts +++ b/apps/buddy/service/src/system/systemCapability.ts @@ -1,4 +1,8 @@ import type { SystemActionApprovalReviewInput } from '../../../shared/permissions/approvalReviewPayload' +import { randomUUID } from 'node:crypto' +import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export const SYSTEM_ACTION_KINDS = [ 'kill-process', @@ -85,6 +89,7 @@ export interface PreparedSystemAction { } interface PreparedSystemActionEntry { + preparationId: string expiresAt: number request: SystemActionRequest target: SystemTarget @@ -96,7 +101,21 @@ export interface SystemActionPreparationRegistryOptions { ttlMs?: number } +export interface SystemPreparationChange { + readonly revision: number + readonly preparationId: string + readonly toolCallId: string + readonly phase: 'prepared' | 'removed' + readonly reason?: 'consumed' | 'expired' | 'evicted' | 'changed' | 'disposed' + readonly action: SystemActionKind + readonly targetKind: SystemTarget['kind'] +} + export class SystemActionPreparationRegistry { + readonly #changes = new Emitter(() => console.error('SYSTEM_PREPARATION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #disposed = false readonly #entries = new Map() readonly #maxEntries: number readonly #now: () => number @@ -108,11 +127,17 @@ export class SystemActionPreparationRegistry { this.#ttlMs = options.ttlMs ?? 5 * 60 * 1_000 } + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, prepared: [...this.#entries].map(([toolCallId, entry]) => ({ toolCallId, preparationId: entry.preparationId, expiresAt: entry.expiresAt, action: entry.request.action, targetKind: entry.target.kind })) }) + } + prepare( toolCallId: string, request: SystemActionRequest, target: SystemTarget, ): PreparedSystemAction { + if (this.#disposed) + throw new Error('SYSTEM_PREPARATION_STOPPED') this.#prune() const existing = this.#entries.get(toolCallId) if (existing) { @@ -121,16 +146,18 @@ export class SystemActionPreparationRegistry { return createPreparedSystemAction(existing) } const entry: PreparedSystemActionEntry = { + preparationId: randomUUID(), expiresAt: this.#now() + this.#ttlMs, request: cloneRequest(request), target: cloneTarget(target), } this.#entries.set(toolCallId, entry) + this.#publish(toolCallId, entry, 'prepared') while (this.#entries.size > this.#maxEntries) { const oldest = this.#entries.keys().next().value if (typeof oldest !== 'string') break - this.#entries.delete(oldest) + this.#remove(oldest, 'evicted') } return createPreparedSystemAction(entry) } @@ -139,19 +166,44 @@ export class SystemActionPreparationRegistry { const entry = this.#entries.get(toolCallId) if (!entry) throw new SystemCapabilityError('SYSTEM_ACTION_NOT_PREPARED') - this.#entries.delete(toolCallId) - if (entry.expiresAt <= this.#now()) + if (entry.expiresAt <= this.#now()) { + this.#remove(toolCallId, 'expired') throw new SystemCapabilityError('SYSTEM_ACTION_EXPIRED') - if (!sameRequest(entry.request, request)) + } + if (!sameRequest(entry.request, request)) { + this.#remove(toolCallId, 'changed') throw new SystemCapabilityError('SYSTEM_ACTION_CHANGED') + } + this.#remove(toolCallId, 'consumed') return cloneTarget(entry.target) } + dispose(): void { + if (this.#disposed) + return + this.#disposed = true + for (const toolCallId of this.#entries.keys()) + this.#remove(toolCallId, 'disposed') + this.#changes.dispose() + } + + #remove(toolCallId: string, reason: SystemPreparationChange['reason']): void { + const entry = this.#entries.get(toolCallId) + if (!entry) + return + this.#entries.delete(toolCallId) + this.#publish(toolCallId, entry, 'removed', reason) + } + + #publish(toolCallId: string, entry: PreparedSystemActionEntry, phase: SystemPreparationChange['phase'], reason?: SystemPreparationChange['reason']): void { + this.#changes.fire(copyEventSnapshot({ revision: ++this.#revision, toolCallId, preparationId: entry.preparationId, phase, ...(reason ? { reason } : {}), action: entry.request.action, targetKind: entry.target.kind })) + } + #prune(): void { const now = this.#now() for (const [toolCallId, entry] of this.#entries) { if (entry.expiresAt <= now) - this.#entries.delete(toolCallId) + this.#remove(toolCallId, 'expired') } } } @@ -161,57 +213,112 @@ export interface SystemCapabilityServiceOptions { host: SystemHostPort } +export interface SystemActionChange { + readonly revision: number + readonly operationId: string + readonly toolCallId: string + readonly action: SystemActionKind + readonly targetKind: SystemTarget['kind'] + readonly phase: 'dispatched' | 'confirmed' | 'verified' | 'failed' + readonly effect: 'not-dispatched' | 'unknown' | 'confirmed' + readonly verified?: boolean + readonly errorCode?: string + readonly cancelled: boolean +} + export class SystemCapabilityService { + readonly #changes = new Emitter(() => console.error('SYSTEM_ACTION_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event readonly #actions: SystemActionPreparationRegistry + readonly #ownsActions: boolean readonly #host: SystemHostPort + readonly #shutdown = new AbortController() + readonly #pending = new Set>() + #revision = 0 + #disposing: Promise | undefined constructor(options: SystemCapabilityServiceOptions) { this.#actions = options.actions ?? new SystemActionPreparationRegistry() + this.#ownsActions = !options.actions this.#host = options.host } - async prepareAction( - toolCallId: string, - input: SystemActionRequest, - signal: AbortSignal, - ): Promise { - signal.throwIfAborted() - validateActionRequest(input) - const targets = await this.#host.resolveTargets(input.target, signal) - if (targets.length === 0) - throw new SystemCapabilityError('SYSTEM_TARGET_NOT_FOUND') - if (targets.length > 1) - throw new SystemCapabilityError('SYSTEM_TARGET_AMBIGUOUS') - const target = targets[0]! - if (!target.allowedActions.includes(input.action)) - throw new SystemCapabilityError('SYSTEM_ACTION_NOT_ALLOWED') - return this.#actions.prepare(toolCallId, input, target) + get onDidChangePreparation() { return this.#actions.onDidChange } + get snapshot() { return copyEventSnapshot({ revision: this.#revision, stopping: this.#shutdown.signal.aborted, pending: this.#pending.size, preparations: this.#actions.snapshot }) } + + prepareAction(toolCallId: string, input: SystemActionRequest, parent: AbortSignal): Promise { + const request = cloneRequest(input) + const signal = AbortSignal.any([parent, this.#shutdown.signal]) + return this.#track(async () => { + signal.throwIfAborted() + validateActionRequest(request) + const targets = await this.#host.resolveTargets(request.target, signal) + signal.throwIfAborted() + if (targets.length === 0) + throw new SystemCapabilityError('SYSTEM_TARGET_NOT_FOUND') + if (targets.length > 1) + throw new SystemCapabilityError('SYSTEM_TARGET_AMBIGUOUS') + const target = targets[0]! + if (!target.allowedActions.includes(request.action)) + throw new SystemCapabilityError('SYSTEM_ACTION_NOT_ALLOWED') + return this.#actions.prepare(toolCallId, request, target) + }) } - async act( - toolCallId: string, - input: SystemActionRequest, - signal: AbortSignal, - ) { - signal.throwIfAborted() - validateActionRequest(input) - const approvedTarget = this.#actions.take(toolCallId, input) - const current = await this.#host.readTarget(approvedTarget, signal) - if (!current || !sameTargetIdentity(approvedTarget, current)) - throw new SystemCapabilityError('SYSTEM_TARGET_CHANGED') - if (!current.allowedActions.includes(input.action)) - throw new SystemCapabilityError('SYSTEM_ACTION_NOT_ALLOWED') - await this.#host.execute(current, input.action, signal) - const postAction = await this.#host.readTarget(current, signal) - const outcome = evaluatePostcondition(input.action, current, postAction) - return { - action: input.action, - message: outcome.message, - observedAt: new Date().toISOString(), - status: outcome.status, - target: targetReview(current), - verified: outcome.verified, - } + act(toolCallId: string, input: SystemActionRequest, parent: AbortSignal) { + const request = cloneRequest(input) + const signal = AbortSignal.any([parent, this.#shutdown.signal]) + const operationId = randomUUID() + return this.#track(async () => { + let effect: SystemActionChange['effect'] = 'not-dispatched' + const publish = (phase: SystemActionChange['phase'], detail: Pick = {}) => { + this.#changes.fire(copyEventSnapshot({ ...detail, revision: ++this.#revision, operationId, toolCallId, action: request.action, targetKind: request.target.kind, phase, effect, cancelled: signal.aborted })) + } + try { + signal.throwIfAborted() + validateActionRequest(request) + const approvedTarget = this.#actions.take(toolCallId, request) + const current = await this.#host.readTarget(approvedTarget, signal) + signal.throwIfAborted() + if (!current || !sameTargetIdentity(approvedTarget, current)) + throw new SystemCapabilityError('SYSTEM_TARGET_CHANGED') + if (!current.allowedActions.includes(request.action)) + throw new SystemCapabilityError('SYSTEM_ACTION_NOT_ALLOWED') + effect = 'unknown' + publish('dispatched') + await this.#host.execute(current, request.action, signal) + effect = 'confirmed' + publish('confirmed') + const postAction = await this.#host.readTarget(current, signal) + const outcome = evaluatePostcondition(request.action, current, postAction) + publish('verified', { verified: outcome.verified }) + return { action: request.action, message: outcome.message, observedAt: new Date().toISOString(), status: outcome.status, target: targetReview(current), verified: outcome.verified } + } + catch (error) { + publish('failed', { errorCode: readDiagnosticErrorCode(error) }) + throw error + } + }) + } + + dispose(): Promise { + this.#disposing ??= Promise.resolve().then(async () => { + await Promise.allSettled([...this.#pending]) + if (this.#ownsActions) + this.#actions.dispose() + this.#changes.dispose() + }) + this.#shutdown.abort() + return this.#disposing + } + + #track(operation: () => Promise): Promise { + if (this.#shutdown.signal.aborted) + return Promise.reject(this.#shutdown.signal.reason) + const pending = Promise.resolve().then(operation) + this.#pending.add(pending) + void pending.finally(() => this.#pending.delete(pending)).catch(() => {}) + return pending } } diff --git a/apps/buddy/service/src/system/systemExtension.ts b/apps/buddy/service/src/system/systemExtension.ts index 5eb55b20..7de5eb04 100644 --- a/apps/buddy/service/src/system/systemExtension.ts +++ b/apps/buddy/service/src/system/systemExtension.ts @@ -1,4 +1,5 @@ import type { TSchema } from 'typebox' +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { BuddyCapability } from '../agent/extensions/BuddyCapability' import type { BuddyInProcessExtension } from '../agent/extensions/BuddyInProcessExtension' import type { SystemActionRequest, SystemHostPort } from './systemCapability' @@ -6,6 +7,7 @@ import type { SystemToolDetails } from './systemToolContract' import type { SystemToolFailureCode } from './systemToolFailure' import { defineTool } from '@earendil-works/pi-coding-agent' import { Check } from 'typebox/value' +import { observeSystemDiagnostics } from './observeSystemDiagnostics' import { SystemCapabilityError, SystemCapabilityService } from './systemCapability' import { @@ -22,9 +24,16 @@ export interface CreateSystemExtensionOptions { service: SystemCapabilityService } -export function createSystemCapability(host: SystemHostPort): BuddyCapability { +export function createSystemCapability(host: SystemHostPort, report?: ApplicationDiagnosticReporter): BuddyCapability { const service = new SystemCapabilityService({ host }) + const diagnostics = report ? observeSystemDiagnostics(service, report) : undefined return { + async dispose() { + try { + await service.dispose() + } + finally { diagnostics?.dispose() } + }, extension: createSystemExtension({ service }), classify: (event, signal) => classifySystemTool(service, event, signal), disclosure: { diff --git a/apps/buddy/service/src/web/WebCapabilityEvents.ts b/apps/buddy/service/src/web/WebCapabilityEvents.ts new file mode 100644 index 00000000..34b9d6bc --- /dev/null +++ b/apps/buddy/service/src/web/WebCapabilityEvents.ts @@ -0,0 +1,16 @@ +import type { WebErrorCode, WebSearchProvider } from '../../../shared/network/webProtocol' + +export interface WebOperationFact { + readonly phase: 'started' | 'attempt-started' | 'attempt-finished' | 'render-fallback' | 'received' | 'cache-published' | 'cache-cleanup-failed' | 'settled' + readonly provider?: WebSearchProvider | 'local' + readonly mode?: 'http' | 'render' | 'remote' + readonly attempt?: number + readonly count?: number + readonly durationMs?: number + readonly errorCode?: WebErrorCode + readonly outcome?: 'completed' | 'failed' + readonly cancelled?: boolean +} + +export type WebCapabilityChange = WebOperationFact & { readonly operationId: string, readonly revision: number, readonly kind: 'search' | 'fetch' } +export type WebOperationObserver = (fact: WebOperationFact) => void diff --git a/apps/buddy/service/src/web/WebCapabilityService.ts b/apps/buddy/service/src/web/WebCapabilityService.ts index eefc0bc2..d0303fe8 100644 --- a/apps/buddy/service/src/web/WebCapabilityService.ts +++ b/apps/buddy/service/src/web/WebCapabilityService.ts @@ -2,13 +2,17 @@ import type { Api, Model, Usage } from '@earendil-works/pi-ai' import type { WebErrorCode } from '../../../shared/network/webProtocol' import type { BuddyDataPaths } from '../storage/BuddyDataPaths' import type { NativeSearchModels } from './NativeWebSearch' +import type { WebCapabilityChange, WebOperationObserver } from './WebCapabilityEvents' import type { WebDocument } from './webContent' import type { WebHostClient } from './WebHostClient' import type { WebSearchResponse } from './webSearchBackends' import type { WebSettingsService } from './WebSettingsService' import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' import { z } from 'zod' import { publicWebUrl } from '../../../platform/network/publicWebTransport' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { WebError, webError } from '../../../shared/network/webProtocol' import { githubWebFetch } from './githubWebFetch' import { nativeSearchFamily, nativeWebSearch } from './NativeWebSearch' @@ -38,6 +42,11 @@ const STOP_FETCH_FALLBACK = new Set([ ]) export class WebCapabilityService { + readonly #changes = new Emitter(() => console.error('WEB_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Map }>() + #revision = 0 + #disposing: Promise | undefined readonly #options: WebCapabilityOptions readonly #shutdown = new AbortController() readonly #cache: WebContentCache @@ -48,12 +57,14 @@ export class WebCapabilityService { this.#cache = new WebContentCache(options.paths) } + get snapshot() { return copyEventSnapshot({ revision: this.#revision, stopping: this.#shutdown.signal.aborted, active: [...this.#pending].map(([operationId, entry]) => ({ operationId, kind: entry.kind })) }) } + async search(input: { query: string, provider?: string, model?: Model, signal?: AbortSignal }): Promise { let availableProviders: string[] = [] let modelUsage: Usage | undefined const nativeId = input.model && nativeSearchFamily(input.model) ? `${input.model.provider}-native` : null const enabled = (provider: string) => this.#options.settings.get().search.some(source => source.enabled && source.provider === (provider === nativeId ? 'native' : provider)) - const result = await this.#run(input.signal, async (signal, attempts) => { + const result = await this.#run('search', input.signal, async (signal, attempts, publish) => { const sources = this.#options.settings.get().search const hasKey = sources.some(source => source.provider === 'tavily' && source.enabled) && await this.#hasTavilyKey() const routes = sources.filter(source => source.enabled && (source.provider !== 'native' || nativeId) && (source.provider !== 'tavily' || hasKey)) @@ -71,6 +82,7 @@ export class WebCapabilityService { continue const started = Date.now() const attemptSignal = AbortSignal.any([signal, AbortSignal.timeout(provider === nativeId ? 50_000 : 15_000)]) + publish({ phase: 'attempt-started', provider: route.provider, attempt: attempts.length + 1 }) try { const providerFetch: WebHostClient['providerFetch'] = (url, init) => { if (!enabled(provider)) @@ -83,11 +95,16 @@ export class WebCapabilityService { ? await tavilySearch(providerFetch, input.query, await this.#key(), attemptSignal) : await publicWebSearch(this.#options.host.get, route.provider, input.query, attemptSignal) attempts.push({ provider, code: null, durationMs: Date.now() - started }) + publish({ phase: 'attempt-finished', provider: route.provider, attempt: attempts.length, count: result.sources.length, durationMs: Date.now() - started }) if (result.sources.length) return { ...result, ok: true as const, provider, query: input.query, attempts } last = result } - catch (error) { attempts.push({ provider, code: webError(error, attemptSignal).code, durationMs: Date.now() - started }) } + catch (error) { + const errorCode = webError(error, attemptSignal).code + attempts.push({ provider, code: errorCode, durationMs: Date.now() - started }) + publish({ phase: 'attempt-finished', provider: route.provider, attempt: attempts.length, errorCode, durationMs: Date.now() - started }) + } } signal.throwIfAborted() if (last && attempts.at(-1)?.code === null) @@ -99,22 +116,23 @@ export class WebCapabilityService { async fetch(input: { url: string, provider?: string, conversationId: string, signal?: AbortSignal }): Promise { let availableProviders: string[] = [] - const result = await this.#run(input.signal, async (signal, attempts) => { + const result = await this.#run('fetch', input.signal, async (signal, attempts, publish) => { const url = publicWebUrl(input.url) const settings = this.#options.settings.get().fetch const hasKey = settings.remote && await this.#hasTavilyKey() - const routes = ['local', ...(hasKey ? ['tavily'] : [])] + const routes: Array<'local' | 'tavily'> = ['local', ...(hasKey ? ['tavily' as const] : [])] availableProviders = routes const selected = !input.provider || input.provider === 'auto' ? routes : routes.filter(provider => provider === input.provider) if (!selected.length) throw new WebError('WEB_PROVIDER_UNAVAILABLE') - await this.#options.host.authorize(url.href) + await this.#options.host.authorize(url.href, signal) for (const provider of selected) { signal.throwIfAborted() if (provider === 'tavily' && !this.#options.settings.get().fetch.remote) continue const started = Date.now() const attemptSignal = AbortSignal.any([signal, AbortSignal.timeout(provider === 'local' ? 45_000 : 20_000)]) + publish({ phase: 'attempt-started', provider, attempt: attempts.length + 1 }) try { let document: WebDocument let mode: 'http' | 'render' | 'remote' = provider === 'local' ? 'http' : 'remote' @@ -127,6 +145,7 @@ export class WebCapabilityService { const failure = webError(error, attemptSignal) if (failure.code !== 'WEB_RENDER_REQUIRED' || !this.#options.settings.get().fetch.render) throw failure + publish({ phase: 'render-fallback', provider, mode: 'render', attempt: attempts.length + 1 }) const rendered = await this.#options.host.render(url.href, attemptSignal) if (!rendered.ok) throw new WebError(rendered.code) @@ -160,13 +179,16 @@ export class WebCapabilityService { document.usage = { credits: result.data.usage.credits, cost: null } } attemptSignal.throwIfAborted() - const output = await this.#publish(document, input.conversationId) + publish({ phase: 'received', provider, mode, count: Buffer.byteLength(document.content) }) + const output = await this.#publish(document, input.conversationId, attemptSignal, publish) attempts.push({ provider, code: null, durationMs: Date.now() - started }) + publish({ phase: 'attempt-finished', provider, mode, attempt: attempts.length, durationMs: Date.now() - started }) return { ...output, ok: true as const, provider, mode, attempts } } catch (error) { const failure = webError(error, attemptSignal) attempts.push({ provider, code: failure.code, durationMs: Date.now() - started }) + publish({ phase: 'attempt-finished', provider, attempt: attempts.length, errorCode: failure.code, durationMs: Date.now() - started }) if (STOP_FETCH_FALLBACK.has(failure.code)) throw failure } @@ -177,7 +199,17 @@ export class WebCapabilityService { return { ...result, availableProviders: availableProviders.filter(provider => provider === 'local' || this.#options.settings.get().fetch.remote) } } - dispose(): void { this.#shutdown.abort() } + dispose(): Promise { + if (this.#disposing) + return this.#disposing + this.#disposing = Promise.resolve().then(async () => { + await Promise.allSettled([...this.#pending.values()].map(entry => entry.promise)) + await this.#cache.dispose() + this.#changes.dispose() + }) + this.#shutdown.abort() + return this.#disposing + } async #hasTavilyKey(): Promise { return Boolean(await this.#options.settings.getTavilyKey().catch(() => null)) @@ -190,32 +222,53 @@ export class WebCapabilityService { return key } - async #publish(document: WebDocument, conversationId: string): Promise { + async #publish(document: WebDocument, conversationId: string, signal: AbortSignal, publish: WebOperationObserver): Promise { const bytes = Buffer.from(document.content) if (bytes.byteLength > 4 * 1024 * 1024) throw new WebError('WEB_RESPONSE_TOO_LARGE') if (bytes.byteLength <= 24 * 1024) return { ...document, outputTruncated: false, contentPath: null } - const contentPath = await this.#cache.write(conversationId, `Source: ${document.url}\nUntrusted external content.\n\n${document.content}`).catch(() => { + const receipt = await this.#cache.write(conversationId, `Source: ${document.url}\nUntrusted external content.\n\n${document.content}`, signal).catch(() => { throw new WebError('WEB_CACHE_FAILED') }) + publish({ phase: 'cache-published', count: receipt.bytes, cancelled: signal.aborted }) + if (receipt.cleanup === 'failed') + publish({ phase: 'cache-cleanup-failed', errorCode: 'WEB_CACHE_FAILED', count: receipt.removed }) let end = 24 * 1024 while ((bytes[end]! & 0xC0) === 0x80) end-- - return { ...document, content: bytes.subarray(0, end).toString('utf8'), outputTruncated: true, contentPath } + return { ...document, content: bytes.subarray(0, end).toString('utf8'), outputTruncated: true, contentPath: receipt.path } } - async #run(parent: AbortSignal | undefined, operation: (signal: AbortSignal, attempts: WebAttempt[]) => Promise): Promise { + #run(kind: 'search' | 'fetch', parent: AbortSignal | undefined, operation: (signal: AbortSignal, attempts: WebAttempt[], publish: WebOperationObserver) => Promise): Promise { const attempts: WebAttempt[] = [] + if (this.#shutdown.signal.aborted) + return Promise.resolve({ ok: false, provider: null, code: 'WEB_CANCELLED', attempts }) if (this.#active >= 4) - return { ok: false, provider: null, code: 'WEB_BUSY', attempts } + return Promise.resolve({ ok: false, provider: null, code: 'WEB_BUSY', attempts }) this.#active++ + const operationId = randomUUID() + const publish: WebOperationObserver = fact => this.#changes.fire(copyEventSnapshot({ ...fact, kind, operationId, revision: ++this.#revision })) const signal = AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(90_000), ...(parent ? [parent] : [])]) - try { - signal.throwIfAborted() - return await operation(signal, attempts) - } - catch (error) { return { ok: false, provider: attempts.at(-1)?.provider ?? null, code: webError(error, signal).code, attempts } } - finally { this.#active-- } + const accepted = Promise.resolve().then(async (): Promise => { + let outcome: 'completed' | 'failed' = 'completed' + const started = performance.now() + publish({ phase: 'started' }) + try { + signal.throwIfAborted() + return await operation(signal, attempts, publish) + } + catch (error) { + outcome = 'failed' + return { ok: false, provider: attempts.at(-1)?.provider ?? null, code: webError(error, signal).code, attempts } + } + finally { + this.#active-- + this.#pending.delete(operationId) + publish({ phase: 'settled', outcome, cancelled: signal.aborted, durationMs: Math.round(performance.now() - started) }) + } + }) + this.#pending.set(operationId, { kind, promise: accepted }) + return accepted } } diff --git a/apps/buddy/service/src/web/WebContentCache.ts b/apps/buddy/service/src/web/WebContentCache.ts index 6a45cb72..c95860d5 100644 --- a/apps/buddy/service/src/web/WebContentCache.ts +++ b/apps/buddy/service/src/web/WebContentCache.ts @@ -3,27 +3,54 @@ import { randomUUID } from 'node:crypto' import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' +export interface WebCacheReceipt { + readonly path: string + readonly bytes: number + readonly cleanup: 'completed' | 'failed' + readonly removed: number +} + export class WebContentCache { readonly #paths: BuddyDataPaths readonly #pending = new Map>() + #disposing: Promise | undefined constructor(paths: BuddyDataPaths) { this.#paths = paths } - write(conversationId: string, content: string): Promise { + write(conversationId: string, content: string, signal?: AbortSignal): Promise { + if (this.#disposing) + return Promise.reject(new Error('WEB_CACHE_STOPPED')) const previous = this.#pending.get(conversationId) ?? Promise.resolve() const result = previous.catch(() => {}).then(async () => { + signal?.throwIfAborted() const directory = join(this.#paths.conversationDirectory(conversationId), 'web-cache') await mkdir(directory, { recursive: true, mode: 0o700 }) const path = join(directory, `${randomUUID()}.txt`) - await writeFile(path, content, { mode: 0o600, flag: 'wx' }) - const entries = await readdir(directory, { withFileTypes: true }) - const files = await Promise.all(entries.filter(entry => entry.isFile() && /^[\da-f-]{36}\.txt$/.test(entry.name)).map(async (entry) => { - const filePath = join(directory, entry.name) - return { path: filePath, mtime: (await stat(filePath)).mtimeMs } - })) - const expired = files.filter(file => file.path !== path).sort((a, b) => b.mtime - a.mtime).slice(31) - await Promise.all(expired.map(file => unlink(file.path))) - return path + signal?.throwIfAborted() + try { + await writeFile(path, content, { mode: 0o600, flag: 'wx' }) + } + catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') + await unlink(path).catch(() => {}) + throw error + } + let removed = 0 + let cleanup: WebCacheReceipt['cleanup'] = 'completed' + try { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all(entries.filter(entry => entry.isFile() && /^[\da-f-]{36}\.txt$/.test(entry.name)).map(async (entry) => { + const filePath = join(directory, entry.name) + return { path: filePath, mtime: (await stat(filePath)).mtimeMs } + })) + const expired = files.filter(file => file.path !== path).sort((a, b) => b.mtime - a.mtime).slice(31) + const results = await Promise.allSettled(expired.map(file => unlink(file.path))) + removed = results.filter(result => result.status === 'fulfilled').length + if (removed !== results.length) + cleanup = 'failed' + } + catch { cleanup = 'failed' } + return { path, bytes: new TextEncoder().encode(content).byteLength, removed, cleanup } }) this.#pending.set(conversationId, result) void result.finally(() => { @@ -32,4 +59,9 @@ export class WebContentCache { }).catch(() => {}) return result } + + dispose(): Promise { + this.#disposing ??= Promise.allSettled([...this.#pending.values()]).then(() => {}) + return this.#disposing + } } diff --git a/apps/buddy/service/src/web/WebHostClient.ts b/apps/buddy/service/src/web/WebHostClient.ts index a0d39dbf..cf759eb6 100644 --- a/apps/buddy/service/src/web/WebHostClient.ts +++ b/apps/buddy/service/src/web/WebHostClient.ts @@ -5,20 +5,50 @@ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { z } from 'zod' import { readResponseBytes } from '../../../platform/network/publicWebTransport' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { WebError, webNetworkChunkSchema, webNetworkHeadSchema, webRenderResultSchema } from '../../../shared/network/webProtocol' +type WebHostRequestKind = 'authorization' | 'network' | 'render' +export interface WebHostChange { + readonly revision: number + readonly requestId: string + readonly kind: WebHostRequestKind + readonly phase: 'requested' | 'head-received' | 'cancel-requested' | 'cancel-unconfirmed' | 'settled' + readonly outcome?: 'received' | 'rejected' | 'unknown' + readonly cancelled?: boolean + readonly count?: number +} + export class WebHostClient { readonly peer: Pick + readonly #changes = new Emitter(() => console.error('WEB_HOST_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #shutdown = new AbortController() + readonly #active = new Map() + readonly #pending = new Set>() + #revision = 0 + #disposing: Promise | undefined constructor(peer: Pick) { this.peer = peer } - async authorize(url: string): Promise { - const result = z.discriminatedUnion('ok', [ - z.object({ ok: z.literal(true) }), - webRenderResultSchema.options[1], - ]).parse(await this.peer.request('host.web.authorize', { url })) - if (!result.ok) - throw new WebError(result.code) + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, stopping: this.#shutdown.signal.aborted, active: [...this.#active].map(([requestId, kind]) => ({ requestId, kind })) }) + } + + async authorize(url: string, parent?: AbortSignal): Promise { + const { requestId, signal } = this.#begin('authorization', parent) + let outcome: WebHostChange['outcome'] = 'unknown' + try { + const result = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true) }), + webRenderResultSchema.options[1], + ]).parse(await this.#track(this.peer.request('host.web.authorize', { url }, undefined, signal))) + outcome = result.ok ? 'received' : 'rejected' + if (!result.ok) + throw new WebError(result.code) + } + finally { this.#finish(requestId, outcome, signal.aborted) } } get: PublicWebGet = async (url, signal, limit = 12 * 1024 * 1024) => { @@ -31,51 +61,70 @@ export class WebHostClient { return result.response } - async render(url: string, signal: AbortSignal): Promise { - signal.throwIfAborted() - const requestId = randomUUID() + async render(url: string, parent: AbortSignal): Promise { + const { requestId, signal } = this.#begin('render', parent) const cancel = () => this.#cancel(requestId) signal.addEventListener('abort', cancel, { once: true }) + let outcome: WebHostChange['outcome'] = 'unknown' try { - const result = await this.peer.request('host.web.render', { url, requestId }, 25_000) + const result = webRenderResultSchema.parse(await this.#track(this.peer.request('host.web.render', { url, requestId }, 25_000, signal))) + outcome = result.ok ? 'received' : 'rejected' signal.throwIfAborted() - return webRenderResultSchema.parse(result) + return result } finally { signal.removeEventListener('abort', cancel) - cancel() + if (outcome === 'unknown' && !signal.aborted) + cancel() + this.#finish(requestId, outcome, signal.aborted) } } - async #request(input: Omit, signal: AbortSignal): Promise<{ response: Response, url: string }> { - signal.throwIfAborted() - const requestId = randomUUID() + dispose(): Promise { + if (this.#disposing) + return this.#disposing + this.#disposing = Promise.resolve().then(async () => { + await Promise.allSettled([...this.#pending]) + this.#changes.dispose() + }) + this.#shutdown.abort() + return this.#disposing + } + + async #request(input: Omit, parent: AbortSignal): Promise<{ response: Response, url: string }> { + const { requestId, signal } = this.#begin('network', parent) let controller: ReadableStreamDefaultController let finished = false + let headSettled = false + let bodyOutcome: NonNullable = 'unknown' + let received = 0 let unsubscribe = () => {} - const cancelRequest = () => this.#cancel(requestId) - function cleanup() { + const cleanup = (outcome: NonNullable) => { if (finished) return finished = true unsubscribe() signal.removeEventListener('abort', cancel) + bodyOutcome = outcome + if (headSettled) + this.#finish(requestId, outcome, signal.aborted, received) } + const cancelRequest = () => this.#cancel(requestId) function cancel() { - if (!finished) { - controller.error(new WebError('WEB_CANCELLED')) - cleanup() - } + if (finished) + return cancelRequest() + controller.error(new WebError('WEB_CANCELLED')) + cleanup('unknown') } const stream = new ReadableStream({ start(value) { controller = value }, cancel: () => { - cleanup() - cancelRequest() + if (!finished) + this.#cancel(requestId) + cleanup('unknown') }, }) - let received = 0 unsubscribe = this.peer.onNotification((method, params) => { if (method !== 'host.web.chunk' || finished) return @@ -85,44 +134,90 @@ export class WebHostClient { const message = parsed.data if (message.code) { controller.error(new WebError(message.code)) - cleanup() + cleanup('rejected') } else if (message.done) { controller.close() - cleanup() + cleanup('received') } else if (message.chunk) { const bytes = Buffer.from(message.chunk, 'base64') received += bytes.byteLength if (received > input.limit) { - controller.error(new WebError('WEB_RESPONSE_TOO_LARGE')) - cleanup() this.#cancel(requestId) + controller.error(new WebError('WEB_RESPONSE_TOO_LARGE')) + cleanup('rejected') } else { controller.enqueue(bytes) } } }) signal.addEventListener('abort', cancel, { once: true }) try { - const result = webNetworkHeadSchema.parse(await this.peer.request('host.web.request', { ...input, requestId }, 60_000)) + const result = webNetworkHeadSchema.parse(await this.#track(this.peer.request('host.web.request', { ...input, requestId }, 60_000, signal))) signal.throwIfAborted() - if (!result.ok) + headSettled = true + if (!result.ok) { + cleanup('rejected') + this.#finish(requestId, 'rejected', signal.aborted, received) throw new WebError(result.code) - const response = new Response([204, 205, 304].includes(result.status) ? null : stream, { status: result.status, headers: result.headers }) + } + this.#publish(requestId, 'network', { phase: 'head-received' }) + if (finished) + this.#finish(requestId, bodyOutcome, signal.aborted, received) + const empty = [204, 205, 304].includes(result.status) + const response = new Response(empty ? null : stream, { status: result.status, headers: result.headers }) + if (empty && !finished) { + this.#cancel(requestId) + cleanup('received') + } return { response, url: result.url } } catch (error) { - cleanup() + if (!finished) + this.#cancel(requestId) + headSettled = true + cleanup('unknown') + this.#finish(requestId, 'unknown', signal.aborted, received) await stream.cancel().catch(() => {}) - this.#cancel(requestId) throw error } } + #begin(kind: WebHostRequestKind, parent?: AbortSignal) { + const signal = AbortSignal.any([this.#shutdown.signal, ...(parent ? [parent] : [])]) + signal.throwIfAborted() + const requestId = randomUUID() + this.#active.set(requestId, kind) + this.#publish(requestId, kind, { phase: 'requested' }) + return { requestId, signal } + } + + #track(promise: Promise): Promise { + this.#pending.add(promise) + void promise.finally(() => this.#pending.delete(promise)).catch(() => {}) + return promise + } + + #finish(requestId: string, outcome: WebHostChange['outcome'], cancelled: boolean, count?: number): void { + const kind = this.#active.get(requestId) + if (!kind) + return + this.#active.delete(requestId) + this.#publish(requestId, kind, { phase: 'settled', outcome, cancelled, ...(count === undefined ? {} : { count }) }) + } + #cancel(requestId: string): void { + const kind = this.#active.get(requestId) + if (!kind) + return + this.#publish(requestId, kind, { phase: 'cancel-requested' }) try { this.peer.notify('host.web.cancel', { requestId }) } - catch {} + catch { this.#publish(requestId, kind, { phase: 'cancel-unconfirmed' }) } + } + + #publish(requestId: string, kind: WebHostRequestKind, fact: Omit): void { + this.#changes.fire(copyEventSnapshot({ ...fact, kind, requestId, revision: ++this.#revision })) } } diff --git a/apps/buddy/service/src/web/WebSettingsService.ts b/apps/buddy/service/src/web/WebSettingsService.ts index 174013ca..15845967 100644 --- a/apps/buddy/service/src/web/WebSettingsService.ts +++ b/apps/buddy/service/src/web/WebSettingsService.ts @@ -1,6 +1,10 @@ +import type { EventSnapshot } from '../../../shared/events/eventTypes' import type { WebSettings, WebSettingsSnapshot } from '../../../shared/network/webProtocol' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { WorkspaceRepository } from '../storage/workspaceRepository' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { webRpc } from '../../../shared/network/webApi' import { DEFAULT_WEB_SETTINGS, webCredentialInputSchema, webSearchSourceSchema, webSettingsSchema } from '../../../shared/network/webProtocol' import { credentialMutationResultSchema, credentialReadResultSchema } from '../../../shared/runtime/credentialProtocol' @@ -13,6 +17,11 @@ const storedSettingsSchema = webSettingsSchema.extend({ }) export class WebSettingsService { + readonly #changes = new Emitter(() => console.error('WEB_SETTINGS_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + #revision = 0 + #configured: boolean | null = null + #disposing: Promise | undefined readonly repository: Pick readonly peer: Pick #mutation: Promise = Promise.resolve() @@ -36,14 +45,22 @@ export class WebSettingsService { return { settings: this.get(), tavilyKeyConfigured: Boolean(await this.getTavilyKey()) } } + get state() { return copyEventSnapshot({ revision: this.#revision, settings: this.get(), tavilyKeyConfigured: this.#configured }) } + async getTavilyKey(): Promise { + const revision = this.#revision const result = credentialReadResultSchema.parse(await this.peer.request('host.secrets.read', { namespace: 'web', id: 'tavily', })) if (!result.ok) throw new HostCredentialStoreError(result.error.code) - return typeof result.value === 'string' && result.value ? result.value : null + const key = typeof result.value === 'string' && result.value ? result.value : null + if (!this.#disposing && revision === this.#revision && this.#configured !== Boolean(key)) { + this.#configured = Boolean(key) + this.#publish('credential-observed', randomUUID()) + } + return key } save(input: unknown): Promise { @@ -52,7 +69,7 @@ export class WebSettingsService { const tavilyKeyConfigured = Boolean(await this.getTavilyKey()) if (!tavilyKeyConfigured && (settings.fetch.remote || settings.search.some(source => source.provider === 'tavily' && source.enabled))) throw new BuddyServiceError('VALIDATION_FAILED') - this.repository.set(SETTINGS_KEY, settings, new Date().toISOString()) + this.#commitSettings(settings, randomUUID()) return { settings, tavilyKeyConfigured } }) } @@ -60,28 +77,64 @@ export class WebSettingsService { saveCredential(input: unknown): Promise { const { key } = parse(webCredentialInputSchema, input) return this.#mutate(async () => { + const operationId = randomUUID() const previouslyConfigured = Boolean(await this.getTavilyKey()) if (key === null || !previouslyConfigured) { const settings = this.get() settings.search = settings.search.map(source => source.provider === 'tavily' ? { ...source, enabled: false } : source) settings.fetch.remote = false - this.repository.set(SETTINGS_KEY, settings, new Date().toISOString()) + this.#commitSettings(settings, operationId) + } + try { + const result = credentialMutationResultSchema.parse(await this.peer.request( + key === null ? 'host.secrets.delete' : 'host.secrets.write', + { namespace: 'web', id: 'tavily', ...(key === null ? {} : { value: key }) }, + )) + if (!result.ok) + throw new HostCredentialStoreError(result.error.code) + this.#configured = key !== null + this.#publish('credential-committed', operationId) + } + catch (error) { + this.#configured = null + this.#publish('credential-failed', operationId) + throw error } - const result = credentialMutationResultSchema.parse(await this.peer.request( - key === null ? 'host.secrets.delete' : 'host.secrets.write', - { namespace: 'web', id: 'tavily', ...(key === null ? {} : { value: key }) }, - )) - if (!result.ok) - throw new HostCredentialStoreError(result.error.code) - return this.snapshot() + return { settings: this.get(), tavilyKeyConfigured: key !== null } }) } #mutate(operation: () => Promise): Promise { + if (this.#disposing) + return Promise.reject(new Error('WEB_SETTINGS_STOPPED')) const result = this.#mutation.then(operation) this.#mutation = result.catch(() => {}) return result } + + #commitSettings(settings: WebSettings, operationId: string): void { + if (JSON.stringify(this.repository.get(SETTINGS_KEY)) === JSON.stringify(settings)) + return + this.repository.set(SETTINGS_KEY, settings, new Date().toISOString()) + this.#publish('settings-committed', operationId) + } + + #publish(kind: WebSettingsChange['kind'], operationId: string): void { + this.#changes.fire(copyEventSnapshot({ kind, operationId, revision: ++this.#revision, settings: this.get(), tavilyKeyConfigured: this.#configured })) + } + + dispose(): Promise { + this.#disposing ??= this.#mutation.then(() => this.#changes.dispose()) + return this.#disposing + } +} + +export interface WebSettingsChange { + readonly kind: 'settings-committed' | 'credential-committed' | 'credential-failed' | 'credential-observed' + readonly operationId: string + readonly revision: number + readonly settings: EventSnapshot + readonly tavilyKeyConfigured: boolean | null } export function registerWebSettingsRpc(rpc: Pick, service: WebSettingsService): () => void { diff --git a/apps/buddy/service/src/web/__tests__/WebCapabilityService.spec.ts b/apps/buddy/service/src/web/__tests__/WebCapabilityService.spec.ts index 3cc8be72..2aac503a 100644 --- a/apps/buddy/service/src/web/__tests__/WebCapabilityService.spec.ts +++ b/apps/buddy/service/src/web/__tests__/WebCapabilityService.spec.ts @@ -2,9 +2,11 @@ import type { WebCapabilityOptions } from '../WebCapabilityService' import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { applicationDiagnosticSchema } from '../../../../shared/diagnostics/applicationDiagnostic' import { DEFAULT_WEB_SETTINGS, WebError } from '../../../../shared/network/webProtocol' import { BuddyDataPaths } from '../../storage/BuddyDataPaths' +import { observeWebDiagnostics } from '../observeWebDiagnostics' import { WebCapabilityService } from '../WebCapabilityService' import { WebContentCache } from '../WebContentCache' @@ -122,12 +124,37 @@ describe('web capability routing', () => { it('bounds concurrent cache retention without crossing conversation boundaries', async () => { const { options } = await fixture() const cache = new WebContentCache(options.paths) - const other = await cache.write('other-conversation', 'Other content') + const { path: other } = await cache.write('other-conversation', 'Other content') expect((await stat(other)).mode & 0o777).toBe(0o600) await Promise.all(Array.from({ length: 35 }, (_, index) => cache.write('conversation', `Content ${index}`))) expect(await readdir(join(options.paths.conversationDirectory('conversation'), 'web-cache'))).toHaveLength(32) expect(await readFile(other, 'utf8')).toBe('Other content') }) + it('keeps publication independent of failed observers and projects only bounded diagnostic fields', async () => { + const { options } = await fixture() + const content = 'private fetched content '.repeat(2000) + options.host.get = async url => ({ bytes: new TextEncoder().encode(content), headers: new Headers({ 'content-type': 'text/plain' }), status: 200, url }) + const service = new WebCapabilityService(options) + const diagnostics: unknown[] = [] + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + service.onDidChange(() => { + throw new Error('observer failed') + }) + observeWebDiagnostics(service, diagnostic => diagnostics.push(applicationDiagnosticSchema.parse(diagnostic))) + try { + const result = await service.fetch({ url: 'https://example.com/private-resource', conversationId: 'conversation' }) + expect(result.ok).toBe(true) + if (!result.ok) + throw new Error('expected cache publication') + expect(await readFile(result.contentPath!, 'utf8')).toContain(content) + expect(diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ event: 'web.fetch.cache_published' }), expect.objectContaining({ event: 'web.fetch.settled_completed' })])) + expect(JSON.stringify(diagnostics)).not.toMatch(/private|https|contentPath/) + expect(service.snapshot.active).toEqual([]) + await service.dispose() + } + finally { error.mockRestore() } + }) + it('does not retry remote extraction when local cache persistence fails', async () => { const { options, settings } = await fixture() settings.fetch.remote = true diff --git a/apps/buddy/service/src/web/__tests__/WebContentCache.spec.ts b/apps/buddy/service/src/web/__tests__/WebContentCache.spec.ts new file mode 100644 index 00000000..07537d76 --- /dev/null +++ b/apps/buddy/service/src/web/__tests__/WebContentCache.spec.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BuddyDataPaths } from '../../storage/BuddyDataPaths' +import { WebContentCache } from '../WebContentCache' + +vi.mock('node:fs/promises', async (original) => { + const actual = await original() + return { ...actual, readdir: vi.fn(actual.readdir), writeFile: vi.fn(actual.writeFile) } +}) + +const roots: string[] = [] +afterEach(async () => { + vi.mocked(readdir).mockReset() + vi.mocked(writeFile).mockReset() + const actual = await vi.importActual('node:fs/promises') + vi.mocked(readdir).mockImplementation(actual.readdir) + vi.mocked(writeFile).mockImplementation(actual.writeFile) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'web-cache-events-')) + roots.push(root) + return new WebContentCache(new BuddyDataPaths(root)) +} + +describe('web cache commit boundary', () => { + it('returns the committed file even when old cache enumeration fails', async () => { + const cache = await fixture() + vi.mocked(readdir).mockRejectedValueOnce(new Error('cleanup-failed')) + const receipt = await cache.write('conversation', 'committed content') + expect(receipt).toMatchObject({ cleanup: 'failed', removed: 0, bytes: 17 }) + expect(await readFile(receipt.path, 'utf8')).toBe('committed content') + await cache.dispose() + }) + + it('finishes an admitted write after cancellation and drains it before disposal', async () => { + const cache = await fixture() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const actual = await vi.importActual('node:fs/promises') + vi.mocked(writeFile).mockImplementationOnce(async (...args) => { + entered.resolve() + await release.promise + return actual.writeFile(...args) + }) + const controller = new AbortController() + const write = cache.write('conversation', 'accepted', controller.signal) + await entered.promise + controller.abort() + let disposed = false + const disposing = cache.dispose().then(() => { + disposed = true + }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve() + const receipt = await write + await disposing + expect(await readFile(receipt.path, 'utf8')).toBe('accepted') + await expect(cache.write('conversation', 'late')).rejects.toThrow('WEB_CACHE_STOPPED') + }) + + it('does not remove an existing file when exclusive creation rejects', async () => { + const cache = await fixture() + const actual = await vi.importActual('node:fs/promises') + let existing = '' + vi.mocked(writeFile).mockImplementationOnce(async (path) => { + existing = String(path) + await actual.writeFile(path, 'previous owner', { flag: 'wx' }) + throw Object.assign(new Error('file already exists'), { code: 'EEXIST' }) + }) + await expect(cache.write('conversation', 'new content')).rejects.toMatchObject({ code: 'EEXIST' }) + expect(await readFile(existing, 'utf8')).toBe('previous owner') + await cache.dispose() + }) + + it('does not start a queued write after its cancellation', async () => { + const cache = await fixture() + const controller = new AbortController() + controller.abort() + await expect(cache.write('conversation', 'not written', controller.signal)).rejects.toThrow() + expect(writeFile).not.toHaveBeenCalled() + await cache.dispose() + }) +}) diff --git a/apps/buddy/service/src/web/__tests__/WebHostClient.spec.ts b/apps/buddy/service/src/web/__tests__/WebHostClient.spec.ts new file mode 100644 index 00000000..79607490 --- /dev/null +++ b/apps/buddy/service/src/web/__tests__/WebHostClient.spec.ts @@ -0,0 +1,77 @@ +import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import type { WebHostChange } from '../WebHostClient' +import { describe, expect, it, vi } from 'vitest' +import { WebHostClient } from '../WebHostClient' + +function fixture() { + const listeners = new Set<(method: string, params: unknown) => void>() + const peer: Pick = { + request: vi.fn(async () => ({ ok: true, status: 200, headers: {}, url: 'https://example.com/private' })), + notify: vi.fn(), + onNotification: (listener) => { + listeners.add(listener) + + return () => listeners.delete(listener) + }, + } + const client = new WebHostClient(peer) + const facts: WebHostChange[] = [] + client.onDidChange(change => facts.push(change)) + const chunk = (params: object) => listeners.forEach(listener => listener('host.web.chunk', params)) + return { client, peer, facts, chunk, listeners } +} + +describe('web host transport facts', () => { + it('keeps a received head distinct from the completed body and closes the subscription', async () => { + const { client, facts, chunk, listeners } = fixture() + const response = await client.providerFetch('https://example.com/private', { method: 'POST', headers: {}, body: 'private-body', signal: new AbortController().signal }) + const requestId = facts[0]!.requestId + expect(client.snapshot.active).toHaveLength(1) + chunk({ requestId, chunk: 'b2s=' }) + chunk({ requestId, done: true }) + expect(await response.text()).toBe('ok') + expect(facts.at(-1)).toMatchObject({ phase: 'settled', outcome: 'received', count: 2 }) + expect(client.snapshot.active).toEqual([]) + expect(listeners.size).toBe(0) + expect(JSON.stringify(facts)).not.toMatch(/private|https/) + await client.dispose() + }) + + it('waits for a valid head when the final body chunk arrives first', async () => { + const { client, facts, peer, chunk } = fixture() + const head = Promise.withResolvers() + peer.request = () => head.promise + const fetching = client.providerFetch('https://example.com', { method: 'POST', headers: {}, body: '', signal: new AbortController().signal }) + const requestId = facts[0]!.requestId + chunk({ requestId, chunk: 'b2s=' }) + chunk({ requestId, done: true }) + expect(facts.map(change => change.phase)).toEqual(['requested']) + head.resolve({ ok: true, status: 200, headers: {}, url: 'https://example.com' }) + expect(await (await fetching).text()).toBe('ok') + expect(facts.map(change => change.phase)).toEqual(['requested', 'head-received', 'settled']) + await client.dispose() + }) + + it('settles an unread response as unknown on shutdown and does not invent host cancellation confirmation', async () => { + const { client, facts, peer, listeners } = fixture() + const response = await client.providerFetch('https://example.com', { method: 'POST', headers: {}, body: 'private-body', signal: new AbortController().signal }) + await client.dispose() + await expect(response.text()).rejects.toThrow() + expect(facts.slice(-2)).toMatchObject([{ phase: 'cancel-requested' }, { phase: 'settled', outcome: 'unknown', cancelled: true }]) + expect(peer.notify).toHaveBeenCalledWith('host.web.cancel', { requestId: facts[0]!.requestId }) + expect(client.snapshot.active).toEqual([]) + expect(listeners.size).toBe(0) + await expect(client.authorize('https://example.com')).rejects.toThrow() + }) + + it('retains unknown outcome when cancellation delivery itself fails', async () => { + const { client, facts, peer } = fixture() + peer.notify = () => { + throw new Error('private-peer-data') + } + await client.providerFetch('https://example.com', { method: 'POST', headers: {}, body: '', signal: new AbortController().signal }) + await client.dispose() + expect(facts.slice(-3)).toMatchObject([{ phase: 'cancel-requested' }, { phase: 'cancel-unconfirmed' }, { phase: 'settled', outcome: 'unknown' }]) + expect(JSON.stringify(facts)).not.toContain('private-peer-data') + }) +}) diff --git a/apps/buddy/service/src/web/__tests__/WebSettingsService.spec.ts b/apps/buddy/service/src/web/__tests__/WebSettingsService.spec.ts index d67d8f00..317321a7 100644 --- a/apps/buddy/service/src/web/__tests__/WebSettingsService.spec.ts +++ b/apps/buddy/service/src/web/__tests__/WebSettingsService.spec.ts @@ -10,18 +10,70 @@ function fixture() { for (const migration of BUDDY_SCHEMA_MIGRATIONS) database.exec(migration.sql) let key: string | null = null + let failing = false const service = new WebSettingsService(createWorkspaceRepository(database), { request: async (method, input) => { if (method === 'host.secrets.read') return { ok: true, value: key } + if (failing) + throw new Error('fixture-private-credential-failure') key = method === 'host.secrets.delete' ? null : (input as { value: string }).value return { ok: true } }, }) - return { service, database } + return { service, database, fail: () => { + failing = true + } } } describe('web settings ownership', () => { + it('publishes the durable disable before a credential mutation fails', async () => { + const { service, database, fail } = fixture() + try { + await service.saveCredential({ key: 'fixture-private-credential' }) + const settings = service.get() + settings.fetch.remote = true + await service.save(settings) + const changes: unknown[] = [] + service.onDidChange(change => changes.push(change)) + fail() + await expect(service.saveCredential({ key: null })).rejects.toThrow('fixture-private-credential-failure') + expect(service.get().fetch.remote).toBe(false) + expect(changes).toMatchObject([{ kind: 'settings-committed', settings: { fetch: { remote: false } } }, { kind: 'credential-failed', tavilyKeyConfigured: null }]) + expect(JSON.stringify(changes)).not.toContain('fixture-private') + await service.dispose() + } + finally { database.close() } + }) + it('fences a late credential read and suppresses unchanged read observations', async () => { + const { service, database } = fixture() + const delayed = Promise.withResolvers() + const original = service.peer.request + let delay = true + service.peer.request = (method, input) => { + if (method === 'host.secrets.read' && delay) { + delay = false + return delayed.promise + } + return original(method, input) + } + try { + const changes: unknown[] = [] + service.onDidChange(change => changes.push(change)) + const reading = service.getTavilyKey() + await service.saveCredential({ key: 'fixture-only-key' }) + const revision = service.state.revision + delayed.resolve({ ok: true, value: null }) + await reading + expect(service.state).toMatchObject({ revision, tavilyKeyConfigured: true }) + await service.getTavilyKey() + expect(service.state.revision).toBe(revision) + expect(JSON.stringify(changes)).not.toContain('fixture-only-key') + await service.dispose() + } + finally { database.close() } + }) + it('saving credentials grants no usage, clearing them revokes both uses, readding does not reenable', async () => { const { service, database } = fixture() try { diff --git a/apps/buddy/service/src/web/observeWebDiagnostics.ts b/apps/buddy/service/src/web/observeWebDiagnostics.ts new file mode 100644 index 00000000..63abe441 --- /dev/null +++ b/apps/buddy/service/src/web/observeWebDiagnostics.ts @@ -0,0 +1,29 @@ +import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { WebCapabilityService } from './WebCapabilityService' +import type { WebHostClient } from './WebHostClient' + +export function observeWebDiagnostics(service: WebCapabilityService, report: ApplicationDiagnosticReporter) { + return service.onDidChange(change => report({ + event: `web.${change.kind}.${change.phase.replaceAll('-', '_')}${change.outcome ? `_${change.outcome}` : ''}${change.cancelled ? '_after_cancel' : ''}`, + component: 'runtime.web', + level: change.errorCode || change.outcome === 'failed' ? 'warn' : 'info', + operationId: change.operationId, + revision: change.revision, + ...(change.provider ? { providerId: change.provider } : {}), + ...(change.errorCode ? { errorCode: change.errorCode } : {}), + ...(change.count === undefined ? {} : { count: change.count }), + ...(change.attempt === undefined ? {} : { attempt: change.attempt }), + ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), + })) +} + +export function observeWebHostDiagnostics(client: WebHostClient, report: ApplicationDiagnosticReporter) { + return client.onDidChange(change => report({ + event: `web.transport.${change.kind}.${change.phase.replaceAll('-', '_')}${change.outcome ? `_${change.outcome}` : ''}${change.cancelled ? '_after_cancel' : ''}`, + component: 'runtime.web_host', + level: change.outcome === 'unknown' || change.outcome === 'rejected' || change.phase === 'cancel-unconfirmed' ? 'warn' : 'info', + requestId: change.requestId, + revision: change.revision, + ...(change.count === undefined ? {} : { count: change.count }), + })) +} diff --git a/apps/buddy/service/src/workspace/WorkspaceStateService.ts b/apps/buddy/service/src/workspace/WorkspaceStateService.ts new file mode 100644 index 00000000..b4fb7a28 --- /dev/null +++ b/apps/buddy/service/src/workspace/WorkspaceStateService.ts @@ -0,0 +1,77 @@ +import type { WorkspaceRepository, WorkspaceSettingRecord } from '../storage/workspaceRepository' +import { randomUUID } from 'node:crypto' +import { legacyWorkspaceStateValueSchema, LOCAL_WORKSPACE_STATE_KEY, localWorkspaceStateValueSchema } from '../../../shared/conversation/workspaceApi' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { BuddyServiceError } from '../rpc/runtimeRequest' + +export interface WorkspaceStateChange { + readonly revision: number + readonly operationId: string + readonly kind: 'committed' | 'normalized' | 'normalization-failed' + readonly setting: 'chat-workspace' +} +export class WorkspaceStateService { + readonly #repository: Pick + readonly #normalize?: (value: unknown) => Promise + readonly #changes = new Emitter(() => console.error('WORKSPACE_STATE_OBSERVER_FAILED')) + readonly onDidChange = this.#changes.event + readonly #pending = new Set>() + #stopped = false + #revision = 0 + + constructor(options: { repository: Pick, normalize?: (value: unknown) => Promise }) { + this.#repository = options.repository + this.#normalize = options.normalize + } + + get snapshot() { + return copyEventSnapshot({ revision: this.#revision, record: this.#repository.getRecord(LOCAL_WORKSPACE_STATE_KEY) }) + } + + read(): Promise | null> { + if (this.#stopped) + return Promise.reject(new BuddyServiceError('RUNTIME_UNAVAILABLE')) + const record = this.#repository.getRecord(LOCAL_WORKSPACE_STATE_KEY) + if (!record || !this.#normalize) + return Promise.resolve(copyEventSnapshot(record)) + const operationId = randomUUID() + const normalize = this.#normalize + const result = Promise.resolve().then(() => normalize(copyEventSnapshot(record.value))).then((value) => { + if (JSON.stringify(value) !== JSON.stringify(record.value)) + this.#publish('normalized', operationId) + return copyEventSnapshot({ ...record, value }) + }, (error: unknown) => { + this.#publish('normalization-failed', operationId) + throw error + }) + this.#pending.add(result) + void result.then(() => this.#pending.delete(result), () => this.#pending.delete(result)) + return result + } + + write(value: unknown): Readonly { + if (this.#stopped) + throw new BuddyServiceError('RUNTIME_UNAVAILABLE') + const parsed = localWorkspaceStateValueSchema.or(legacyWorkspaceStateValueSchema).parse(value) + const previous = this.#repository.getRecord(LOCAL_WORKSPACE_STATE_KEY) + if (previous && JSON.stringify(previous.value) === JSON.stringify(parsed)) + return copyEventSnapshot(previous) + this.#repository.set(LOCAL_WORKSPACE_STATE_KEY, parsed, new Date().toISOString()) + const record = this.#repository.getRecord(LOCAL_WORKSPACE_STATE_KEY) + if (!record) + throw new BuddyServiceError('VALIDATION_FAILED') + this.#publish('committed', randomUUID()) + return copyEventSnapshot(record) + } + + async dispose(): Promise { + this.#stopped = true + await Promise.allSettled([...this.#pending]) + this.#changes.dispose() + } + + #publish(kind: WorkspaceStateChange['kind'], operationId: string): void { + this.#changes.fire(Object.freeze({ revision: ++this.#revision, operationId, kind, setting: 'chat-workspace' })) + } +} diff --git a/apps/buddy/service/src/workspace/__tests__/WorkspaceStateService.spec.ts b/apps/buddy/service/src/workspace/__tests__/WorkspaceStateService.spec.ts new file mode 100644 index 00000000..0a8fadf0 --- /dev/null +++ b/apps/buddy/service/src/workspace/__tests__/WorkspaceStateService.spec.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { openBuddyDatabase } from '../../storage/database' +import { createWorkspaceRepository } from '../../storage/workspaceRepository' +import { WorkspaceStateService } from '../WorkspaceStateService' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() +}) +function setup(normalize?: (value: unknown) => Promise) { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const service = new WorkspaceStateService({ repository: createWorkspaceRepository(database), normalize }) + cleanups.push(async () => { + await service.dispose() + database.close() + }) + const events: string[] = [] + service.onDidChange(event => events.push(event.kind)) + return { service, events } +} + +describe('workspace state ownership', () => { + it('validates before persistence and only emits a real commit, with an owned read snapshot', async () => { + const { service, events } = setup() + expect(() => service.write({ invalid: true })).toThrow() + expect(await service.read()).toBeNull() + const value = { activeConversationId: 'conversation-1', spaceId: null } + const first = service.write(value) + value.activeConversationId = 'external-mutation' + expect(service.write({ activeConversationId: 'conversation-1', spaceId: null })).toEqual(first) + expect((await service.read())?.value).toEqual({ activeConversationId: 'conversation-1', spaceId: null }) + expect(Object.isFrozen(first.value)).toBe(true) + expect(events).toEqual(['committed']) + }) + + it('distinguishes normalization from persistence and drains accepted normalization before disposal', async () => { + const pending = Promise.withResolvers() + const { service, events } = setup(() => pending.promise) + service.write({ activeConversationId: null, spaceId: null }) + const reading = service.read() + const stopping = service.dispose() + await expect(service.read()).rejects.toMatchObject({ code: 'RUNTIME_UNAVAILABLE' }) + pending.resolve({ activeConversationId: 'recovered', spaceId: null }) + expect((await reading)?.value).toEqual({ activeConversationId: 'recovered', spaceId: null }) + await stopping + expect(events).toEqual(['committed', 'normalized']) + expect(service.snapshot.record?.value).toEqual({ activeConversationId: null, spaceId: null }) + }) + + it('retains the committed workspace when a later compatibility normalization fails', async () => { + const { service, events } = setup(async () => { + throw new Error('failed after resource import') + }) + service.write({ activeConversationId: null, spaceId: null }) + await expect(service.read()).rejects.toThrow('failed after resource import') + expect(events).toEqual(['committed', 'normalization-failed']) + expect(service.snapshot.record).not.toBeNull() + }) +}) diff --git a/apps/buddy/service/src/workspace/normalizeComposerWorkspace.ts b/apps/buddy/service/src/workspace/normalizeComposerWorkspace.ts index 77d07198..2c78ef70 100644 --- a/apps/buddy/service/src/workspace/normalizeComposerWorkspace.ts +++ b/apps/buddy/service/src/workspace/normalizeComposerWorkspace.ts @@ -30,7 +30,7 @@ export async function normalizeComposerWorkspace(value: unknown, options: { if (attrs.kind === 'file') { if (!spaceId) throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') - const resource = await options.resources.selectSpaceFilePath(draftId, spaceId, value) + const resource = await options.resources.selectSpaceFilePath(draftId, spaceId, value, 'compatibility') return { type: 'chatResourceReference', attrs: { resourceId: resource.resourceId } } } if (attrs.kind === 'skill') diff --git a/apps/buddy/service/src/workspace/registerWorkspaceStateRpc.ts b/apps/buddy/service/src/workspace/registerWorkspaceStateRpc.ts index 07c3e871..b7753da5 100644 --- a/apps/buddy/service/src/workspace/registerWorkspaceStateRpc.ts +++ b/apps/buddy/service/src/workspace/registerWorkspaceStateRpc.ts @@ -1,29 +1,12 @@ import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' -import type { WorkspaceRepository } from '../storage/workspaceRepository' +import type { WorkspaceStateService } from './WorkspaceStateService' import { workspaceStateRpc } from '../../../shared/conversation/workspaceApi' -import { BuddyServiceError, registerRuntimeRequest } from '../rpc/runtimeRequest' +import { registerRuntimeRequest } from '../rpc/runtimeRequest' -export interface RegisterWorkspaceStateRpcOptions { - normalize?: (value: unknown) => Promise - repository: Pick - rpc: RuntimeRequestRegistrar -} - -export function registerWorkspaceStateRpc( - options: RegisterWorkspaceStateRpcOptions, -): () => void { +export function registerWorkspaceStateRpc(options: { service: Pick, rpc: RuntimeRequestRegistrar }): () => void { const disposers = [ - registerRuntimeRequest(options.rpc, workspaceStateRpc.read, async (input) => { - const record = options.repository.getRecord(input.key) - return record && options.normalize ? { ...record, value: await options.normalize(record.value) } : record - }), - registerRuntimeRequest(options.rpc, workspaceStateRpc.write, (input) => { - options.repository.set(input.key, input.value, new Date().toISOString()) - const record = options.repository.getRecord(input.key) - if (!record) - throw new BuddyServiceError('VALIDATION_FAILED') - return record - }), + registerRuntimeRequest(options.rpc, workspaceStateRpc.read, () => options.service.read()), + registerRuntimeRequest(options.rpc, workspaceStateRpc.write, input => options.service.write(input.value)), ] return () => disposers.splice(0).forEach(dispose => dispose()) } diff --git a/apps/buddy/shared/artifacts/artifactApi.ts b/apps/buddy/shared/artifacts/artifactApi.ts index 604c04ec..d93a62d3 100644 --- a/apps/buddy/shared/artifacts/artifactApi.ts +++ b/apps/buddy/shared/artifacts/artifactApi.ts @@ -45,3 +45,12 @@ export const artifactsResponseSchemas = { export const artifactsRpc = { readText: { method: 'artifacts.readText', input: artifactsRequestSchemas.artifactText, response: artifactsResponseSchemas.artifactText }, } as const satisfies Record + +export const artifactChangeNoticeSchema = z.object({ + sourceId: z.uuid(), + revision: z.number().int().positive(), + conversationId: idSchema, +}).strict() + +export type ArtifactChangeNotice = z.infer +export const artifactsChanged = { method: 'artifacts.changed', params: artifactChangeNoticeSchema } as const diff --git a/apps/buddy/shared/changes/changeApi.ts b/apps/buddy/shared/changes/changeApi.ts index 83fc6250..bbffb224 100644 --- a/apps/buddy/shared/changes/changeApi.ts +++ b/apps/buddy/shared/changes/changeApi.ts @@ -63,3 +63,13 @@ export const changesRpc = { overview: { method: 'changes.overview', input: changeOverviewRequestSchema, response: changeOverviewSchema }, get: { method: 'changes.get', input: changesRequestSchemas.changeSet, response: changesResponseSchemas.changeSet }, } as const satisfies Record + +export const changeSetChangeNoticeSchema = z.object({ + sourceId: z.uuid(), + revision: z.number().int().positive(), + conversationId: idSchema, + runId: idSchema, +}).strict() + +export type ChangeSetChangeNotice = z.infer +export const changesChanged = { method: 'changes.changed', params: changeSetChangeNoticeSchema } as const diff --git a/apps/buddy/shared/connectors/connectorApi.ts b/apps/buddy/shared/connectors/connectorApi.ts index fbcf25b2..693f7906 100644 --- a/apps/buddy/shared/connectors/connectorApi.ts +++ b/apps/buddy/shared/connectors/connectorApi.ts @@ -96,3 +96,15 @@ export const connectorsRpc = { saveCredential: { method: 'connectors.saveCredential', input: connectorsRequestSchemas.connectorCredential, response: validationResponseSchemas.mutation }, clearCredential: { method: 'connectors.clearCredential', input: connectorsRequestSchemas.connectorId, response: validationResponseSchemas.mutation }, } as const satisfies Record + +export const connectorChangeNoticeSchema = z.object({ + sourceId: z.uuid(), + revision: z.number().int().positive(), + generation: z.number().int().nonnegative(), + connectorId: connectorBaseSchema.shape.id, + type: z.enum(['configuration', 'credential', 'login', 'runtime', 'catalog']), +}).strict() +export type ConnectorChangeNotice = Readonly> +export const connectorNotifications = { + changed: { method: 'connectors.changed', params: connectorChangeNoticeSchema }, +} as const diff --git a/apps/buddy/shared/conversation/composerApi.ts b/apps/buddy/shared/conversation/composerApi.ts index 218df78c..1b47a068 100644 --- a/apps/buddy/shared/conversation/composerApi.ts +++ b/apps/buddy/shared/conversation/composerApi.ts @@ -66,3 +66,6 @@ export const composerDraftsRpc = { get: { method: 'composerDrafts.get', input: composerRequestSchemas.composerDraftTarget, response: composerResponseSchemas.composerDraft }, save: { method: 'composerDrafts.save', input: composerRequestSchemas.composerDraftSave, response: composerResponseSchemas.composerDraft }, } as const satisfies Record + +export const composerResourcesChanged = { method: 'composerResources.changed', params: z.object({ revision: z.number().int().nonnegative(), draftIds: z.array(sessionIdentitySchema) }).strict() } as const +export type ComposerResourcesChanged = z.infer diff --git a/apps/buddy/shared/conversation/conversationApi.ts b/apps/buddy/shared/conversation/conversationApi.ts index b2ff58c3..72266302 100644 --- a/apps/buddy/shared/conversation/conversationApi.ts +++ b/apps/buddy/shared/conversation/conversationApi.ts @@ -7,6 +7,8 @@ import { approvalPolicySchema, executionProfileSchema, idSchema, nullableTimesta import { attachmentSchema } from './attachmentApi' import { BUDDY_SERVICE_TIERS, BUDDY_THINKING_LEVELS } from './modelSelection' +export const CONVERSATION_CHANGED = 'conversations.changed' + export const modelSelectionSchema = z.object({ modelId: idSchema, providerId: idSchema, diff --git a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts index a18b5eab..4daee862 100644 --- a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts +++ b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts @@ -1,8 +1,10 @@ +import type { LifecycleFailure } from '../lifecycle/lifecycleFailure' import { z } from 'zod' -import { readLocalChatErrorCode } from '../runtime/localChatError' +import { extensionIdSchema } from '../extensions/extensionManifest' +import { readLifecycleFailure } from '../lifecycle/lifecycleFailure' import { desktopBootstrapFailureSchema, processExitSchema, rendererLoadFailureSchema } from './desktopStartupDiagnostic' import { networkStartupFailureSchema } from './networkStartupFailure' -import { privateDirectoryErrorCodeSchema, privateDirectoryFailureSchema } from './privateDirectoryFailure' +import { privateDirectoryFailureSchema } from './privateDirectoryFailure' import { providerRequestDiagnosticSchema } from './providerRequestDiagnostic' export const APPLICATION_DIAGNOSTIC_METHOD = 'application.diagnostic' @@ -15,6 +17,14 @@ export const applicationDiagnosticSchema = z.object({ level: z.enum(['debug', 'info', 'warn', 'error']), operationId: diagnosticIdentitySchema.optional(), parentOperationId: diagnosticIdentitySchema.optional(), + producerInstanceId: z.uuid().optional(), + extensionId: extensionIdSchema.optional(), + workingCopyId: z.uuid().optional(), + markId: z.uuid().optional(), + revision: z.number().int().nonnegative().optional(), + contentVersion: z.number().int().nonnegative().optional(), + savedVersion: z.number().int().nonnegative().optional(), + dirty: z.boolean().optional(), generation: diagnosticIdentitySchema.optional(), sessionId: diagnosticIdentitySchema.optional(), providerId: diagnosticIdentitySchema.optional(), @@ -23,6 +33,8 @@ export const applicationDiagnosticSchema = z.object({ occurrenceId: diagnosticIdentitySchema.optional(), toolCallId: diagnosticIdentitySchema.optional(), conversationId: diagnosticIdentitySchema.optional(), + spaceId: diagnosticIdentitySchema.optional(), + directoryId: diagnosticIdentitySchema.optional(), branchId: diagnosticIdentitySchema.optional(), runId: diagnosticIdentitySchema.optional(), turnId: diagnosticIdentitySchema.optional(), @@ -46,7 +58,7 @@ export const applicationDiagnosticSchema = z.object({ export type ApplicationDiagnostic = z.infer export type ApplicationDiagnosticReporter = (event: ApplicationDiagnostic) => void -export type DiagnosticError = Pick +export type DiagnosticError = LifecycleFailure export function safeDiagnosticReporter(report?: ApplicationDiagnosticReporter): ApplicationDiagnosticReporter { return (event) => { @@ -57,35 +69,8 @@ export function safeDiagnosticReporter(report?: ApplicationDiagnosticReporter): } } +export const readDiagnosticError = readLifecycleFailure + export function readDiagnosticErrorCode(error: unknown): string { return readDiagnosticError(error).errorCode ?? 'OPERATION_FAILED' } - -export function readDiagnosticError(error: unknown): DiagnosticError { - let errorCode: string | null = readLocalChatErrorCode(error) - let failure: ApplicationDiagnostic['failure'] - const visited = new Set() - for (let current = error; current && typeof current === 'object' && visited.size < 8 && !visited.has(current); current = 'cause' in current ? current.cause : undefined) { - visited.add(current) - const code = 'code' in current ? current.code : undefined - const privateDirectoryCode = privateDirectoryErrorCodeSchema.safeParse(code) - if (!errorCode) { - if (privateDirectoryCode.success) - errorCode = privateDirectoryCode.data - else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'NETWORK_START_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code)) - errorCode = code - } - if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED' || code === 'NETWORK_START_FAILED')) { - const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : code === 'NETWORK_START_FAILED' ? networkStartupFailureSchema : desktopBootstrapFailureSchema - const parsed = schema.safeParse('failure' in current ? current.failure : undefined) - if (parsed.success) - failure = parsed.data - } - } - const errorType = applicationDiagnosticSchema.shape.errorType.safeParse(error instanceof Error ? error.name : 'UnknownError') - return { - errorCode: errorCode ?? 'OPERATION_FAILED', - errorType: errorType.success ? errorType.data : 'UnknownError', - ...(failure ? { failure } : {}), - } -} diff --git a/apps/buddy/shared/diagnostics/rendererDiagnostic.ts b/apps/buddy/shared/diagnostics/rendererDiagnostic.ts new file mode 100644 index 00000000..91210ca2 --- /dev/null +++ b/apps/buddy/shared/diagnostics/rendererDiagnostic.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { applicationDiagnosticSchema } from './applicationDiagnostic' + +export const rendererDiagnosticSchema = applicationDiagnosticSchema.pick({ + level: true, + operationId: true, + workingCopyId: true, + revision: true, + contentVersion: true, + savedVersion: true, + dirty: true, + durationMs: true, + count: true, +}).extend({ + event: z.enum([ + 'workbench.copy.registered', + 'workbench.copy.restored', + 'workbench.copy.released', + 'workbench.copy.load_started', + 'workbench.copy.loaded', + 'workbench.copy.load_failed', + 'workbench.copy.dirty_changed', + 'workbench.copy.save_started', + 'workbench.copy.saved', + 'workbench.copy.save_conflict', + 'workbench.copy.save_failed', + 'workbench.copy.conflict_resolved', + 'workbench.copy.discarded', + 'workbench.command.started', + 'workbench.command.completed', + 'workbench.command.failed', + 'workbench.layout.opened', + 'workbench.layout.moved', + 'workbench.layout.closed', + 'workbench.layout.restored', + 'workbench.layout.interaction_removed', + 'workbench.close.closed', + 'workbench.close.cleanup_pending', + 'workbench.contributions.registered', + 'workbench.contributions.removed', + ]), + operationId: z.uuid().optional(), + sourceSequence: z.number().int().positive(), + occurredAt: z.iso.datetime(), +}).strict() + +export const rendererDiagnosticReportSchema = z.object({ + sourceId: z.uuid(), + diagnostic: rendererDiagnosticSchema, +}).strict() + +export type RendererDiagnostic = z.infer +export type RendererDiagnosticReport = z.infer +export interface RendererDiagnosticApi { + report: (input: RendererDiagnosticReport) => Promise +} diff --git a/apps/buddy/shared/events/Emitter.ts b/apps/buddy/shared/events/Emitter.ts new file mode 100644 index 00000000..5b92e9c0 --- /dev/null +++ b/apps/buddy/shared/events/Emitter.ts @@ -0,0 +1,112 @@ +import type { EventSubscription, EventSubscriptionOptions } from './eventTypes' + +export type Event = (listener: (value: Value) => unknown, options?: EventSubscriptionOptions) => EventSubscription +export type ListenerErrorHandler = (error: unknown) => void + +interface Listener { + active: boolean + callback: (value: Value) => unknown + subscription: EventSubscription + once: boolean +} + +export class Emitter { + readonly #listeners = new Set>() + readonly #pending: { value: Value, listeners: Listener[] }[] = [] + readonly #onListenerError: ListenerErrorHandler + #delivering = false + #disposed = false + + constructor(onListenerError: ListenerErrorHandler) { + this.#onListenerError = onListenerError + } + + readonly event: Event = (callback, options = {}) => { + if (this.#disposed || options.signal?.aborted) + return { dispose() {} } + const listener: Listener = { + active: true, + callback, + once: options.once ?? false, + subscription: { dispose: () => { + if (!listener.active) + return + listener.active = false + this.#listeners.delete(listener) + options.signal?.removeEventListener('abort', listener.subscription.dispose) + } }, + } + this.#listeners.add(listener) + options.signal?.addEventListener('abort', listener.subscription.dispose, { once: true }) + return listener.subscription + } + + fire(value: Value): void { + this.fireBatch([value]) + } + + fireBatch(values: readonly Value[]): void { + if (this.#disposed) + return + for (const value of values) + this.#pending.push({ value, listeners: [...this.#listeners] }) + if (this.#delivering) + return + this.#delivering = true + try { + for (let index = 0; index < this.#pending.length; index++) { + const delivery = this.#pending[index]! + for (const listener of delivery.listeners) { + if (!listener.active) + continue + if (listener.once) + listener.subscription.dispose() + try { + const result = listener.callback(delivery.value) + if (result !== undefined) + void Promise.resolve(result).catch(error => this.#report(error)) + } + catch (error) { + this.#report(error) + } + } + } + } + finally { + this.#pending.length = 0 + this.#delivering = false + } + } + + dispose(): void { + this.#disposed = true + for (const listener of this.#listeners) + listener.subscription.dispose() + } + + #report(error: unknown): void { + try { + this.#onListenerError(error) + } + catch {} + } +} + +export function filterEvent(event: Event, predicate: (value: Value) => value is Selected): Event +export function filterEvent(event: Event, predicate: (value: Value) => boolean): Event +export function filterEvent(event: Event, predicate: (value: Value) => boolean): Event { + return (listener, options) => { + const subscription = event((value) => { + if (!predicate(value)) + return + if (options?.once) + subscription.dispose() + return listener(value) + }, { ...options, once: false }) + return subscription + } +} + +export function mapEvent(event: Event, map: (value: Value) => Mapped): Event { + return (listener, options) => event(value => listener(map(value)), options) +} diff --git a/apps/buddy/shared/events/EventBus.ts b/apps/buddy/shared/events/EventBus.ts new file mode 100644 index 00000000..fad9e63b --- /dev/null +++ b/apps/buddy/shared/events/EventBus.ts @@ -0,0 +1,98 @@ +import type { EventMessage, EventName, EventPattern, EventSubscriber, EventSubscription, EventSubscriptionOptions } from './eventTypes' +import { EventEmitter2 } from 'eventemitter2' +import { copyEventSnapshot } from './eventSnapshot' + +export class EventBus implements EventSubscriber { + readonly #emitter = new EventEmitter2({ wildcard: true, delimiter: ':', maxListeners: 64 }) + readonly #subscriptions = new Set() + readonly #onListenerError: (error: unknown, event: EventMessage) => void + readonly #pending: { event: EventMessage, listeners: ReturnType }[] = [] + #delivering = false + #disposed = false + + constructor(onListenerError: (error: unknown, event: EventMessage) => void) { + this.#onListenerError = onListenerError + } + + readonly subscriber: EventSubscriber = Object.freeze({ on: this.on.bind(this) }) + + on>(patterns: Pattern | readonly Pattern[], listener: (event: EventMessage) => unknown, options: EventSubscriptionOptions = {}): EventSubscription { + if (this.#disposed) + throw new Error('EVENT_BUS_DISPOSED') + const names = [...new Set(typeof patterns === 'string' ? [patterns] : patterns)] + if (names.some(pattern => typeof pattern !== 'string' || !/^(?:[a-z][a-z0-9-]*:)*(?:[a-z][a-z0-9-]*|\*|\*\*)$/.test(pattern) || pattern.split(':').some(part => part === 'constructor' || part === 'prototype'))) + throw new Error('EVENT_PATTERN_INVALID') + if (typeof listener !== 'function') + throw new TypeError('EVENT_LISTENER_INVALID') + if (!names.length || options.signal?.aborted) + return { dispose() {} } + let active = true + let subscription: EventSubscription + const wrapped = (event: EventMessage) => { + if (!active) + return + if (options.once) + subscription.dispose() + return listener(event) + } + subscription = { dispose: () => { + if (!active) + return + active = false + for (const name of names) this.#emitter.off(name, wrapped) + this.#subscriptions.delete(subscription) + options.signal?.removeEventListener('abort', subscription.dispose) + } } + for (const name of names) this.#emitter.on(name, wrapped) + this.#subscriptions.add(subscription) + options.signal?.addEventListener('abort', subscription.dispose, { once: true }) + return subscription + } + + hasListeners(name: EventName): boolean { + return this.#subscriptions.size > 0 && Boolean(this.#emitter.hasListeners(name)) + } + + emit(message: EventMessage): boolean { + if (!this.#subscriptions.size || this.#disposed) + return false + const listeners = [...new Set(this.#emitter.listeners(message.type))] + if (!listeners.length) + return false + this.#pending.push({ event: copyEventSnapshot(message) as EventMessage, listeners }) + if (this.#delivering) + return true + this.#delivering = true + try { + for (let index = 0; index < this.#pending.length; index++) { + const { event, listeners } = this.#pending[index]! + for (const listener of listeners) { + try { + const result: unknown = listener(event) + if (result !== undefined) + void Promise.resolve(result).catch(error => this.#report(error, event)) + } + catch (error) { this.#report(error, event) } + } + } + } + finally { + this.#pending.length = 0 + this.#delivering = false + } + return true + } + + dispose(): void { + this.#disposed = true + for (const subscription of this.#subscriptions) + subscription.dispose() + } + + #report(error: unknown, event: EventMessage): void { + try { + this.#onListenerError(error, event) + } + catch {} + } +} diff --git a/apps/buddy/shared/events/ReadonlyMapView.ts b/apps/buddy/shared/events/ReadonlyMapView.ts new file mode 100644 index 00000000..8327ebfe --- /dev/null +++ b/apps/buddy/shared/events/ReadonlyMapView.ts @@ -0,0 +1,19 @@ +export class ReadonlyMapView implements ReadonlyMap { + readonly #source: ReadonlyMap + + constructor(source: ReadonlyMap) { + this.#source = source + } + + get size(): number { return this.#source.size } + get(key: Key): Value | undefined { return this.#source.get(key) } + has(key: Key): boolean { return this.#source.has(key) } + entries(): MapIterator<[Key, Value]> { return this.#source.entries() } + keys(): MapIterator { return this.#source.keys() } + values(): MapIterator { return this.#source.values() } + [Symbol.iterator](): MapIterator<[Key, Value]> { return this.entries() } + forEach(callback: (value: Value, key: Key, map: ReadonlyMap) => void, thisArg?: unknown): void { + for (const [key, value] of this.#source) + callback.call(thisArg, value, key, this) + } +} diff --git a/apps/buddy/shared/events/__tests__/Emitter.spec.ts b/apps/buddy/shared/events/__tests__/Emitter.spec.ts new file mode 100644 index 00000000..0bebfc7a --- /dev/null +++ b/apps/buddy/shared/events/__tests__/Emitter.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { Emitter, filterEvent } from '../Emitter' +import { copyEventSnapshot } from '../eventSnapshot' + +describe('service events', () => { + it('delivers reentrant facts in source order with emission-time listener membership', () => { + const emitter = new Emitter(() => {}) + const seen: string[] = [] + emitter.event((value) => { + seen.push(`first:${value}`) + if (value === 1) { + emitter.fire(2) + emitter.event(value => seen.push(`late:${value}`)) + } + }) + emitter.event(value => seen.push(`second:${value}`)) + emitter.fire(1) + emitter.fire(3) + expect(seen).toEqual(['first:1', 'second:1', 'first:2', 'second:2', 'first:3', 'second:3', 'late:3']) + }) + + it('isolates synchronous, asynchronous and error-handler failures without losing siblings', async () => { + const errors: unknown[] = [] + const emitter = new Emitter((error) => { + errors.push(error) + throw new Error('sink unavailable') + }) + const seen: number[] = [] + emitter.event(() => { + throw new Error('sync') + }) + emitter.event(async () => { + throw new Error('async') + }) + emitter.event(value => seen.push(value)) + expect(() => emitter.fire(1)).not.toThrow() + await Promise.resolve() + expect(seen).toEqual([1]) + expect(errors).toHaveLength(2) + }) + + it('owns duplicate subscriptions, aborts queued deliveries and removes once before reentry', () => { + const emitter = new Emitter(() => {}) + const seen: number[] = [] + const listener = (value: number) => seen.push(value) + const first = emitter.event(listener) + const controller = new AbortController() + emitter.event((value) => { + if (value === 1) { + emitter.fire(2) + controller.abort() + } + }, { once: true }) + emitter.event(listener, { signal: controller.signal }) + first.dispose() + emitter.fire(1) + emitter.fire(3) + expect(seen).toEqual([]) + emitter.dispose() + emitter.event(listener) + emitter.fire(4) + expect(seen).toEqual([]) + }) + + it('applies once to the selected event and freezes independent event-time snapshots', () => { + const emitter = new Emitter(() => {}) + const seen: number[] = [] + filterEvent(emitter.event, value => value > 1)(value => seen.push(value), { once: true }) + emitter.fire(1) + emitter.fire(2) + emitter.fire(3) + expect(seen).toEqual([2]) + const value = { nested: { ids: ['a'] }, callback: () => 'accepted' } + const snapshot = copyEventSnapshot(value) + value.nested.ids.push('b') + expect(snapshot.nested.ids).toEqual(['a']) + expect(() => (snapshot.nested.ids as string[]).push('c')).toThrow() + expect(snapshot.callback()).toBe('accepted') + }) +}) diff --git a/apps/buddy/shared/events/__tests__/EventBus.spec.ts b/apps/buddy/shared/events/__tests__/EventBus.spec.ts new file mode 100644 index 00000000..7f6e9d5d --- /dev/null +++ b/apps/buddy/shared/events/__tests__/EventBus.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { EventBus } from '../EventBus' + +interface Events { + 'message': string + 'configuration:changed': { enabled: boolean } + 'workbench:panes:changed': number + 'workbench:visible': boolean +} +function fail(error: unknown) { + throw error +} + +describe('scoped event bus', () => { + it('delivers reentrant facts in FIFO order with event-time snapshots', () => { + const bus = new EventBus(fail) + const seen: string[] = [] + bus.on('message', ({ data }) => { + seen.push(`first:${data}`) + if (data === 'outer') + bus.emit({ type: 'message', data: 'inner' }) + }) + bus.on('message', ({ data }) => seen.push(`second:${data}`)) + bus.emit({ type: 'message', data: 'outer' }) + expect(seen).toEqual(['first:outer', 'second:outer', 'first:inner', 'second:inner']) + }) + + it('matches exact, single-level and recursive namespaces without delivering to other scopes', async () => { + const bus = new EventBus(fail) + const other = new EventBus(fail) + const seen: string[] = [] + bus.on('*', event => seen.push(`root:${event.type}`)) + bus.on('workbench:*', (event) => { + expectTypeOf(event.data).toEqualTypeOf() + seen.push(`child:${event.type}`) + }) + bus.on('workbench:**', event => seen.push(`tree:${event.type}`)) + bus.on('configuration:changed', (event) => { + expectTypeOf(event.data).toEqualTypeOf<{ enabled: boolean }>() + seen.push(`config:${event.data.enabled}`) + }) + bus.on('**', event => seen.push(`all:${event.type}`)) + expect(other.emit({ type: 'configuration:changed', data: { enabled: false } })).toBe(false) + expect(await other.emit({ type: 'message', data: 'none' })).toBe(false) + expect(bus.emit({ type: 'message', data: 'hello' })).toBe(true) + bus.emit({ type: 'workbench:visible', data: true }) + bus.emit({ type: 'workbench:panes:changed', data: 2 }) + await bus.emit({ type: 'configuration:changed', data: { enabled: true } }) + expect(seen.sort()).toEqual([ + 'root:message', + 'all:message', + 'child:workbench:visible', + 'tree:workbench:visible', + 'all:workbench:visible', + 'tree:workbench:panes:changed', + 'all:workbench:panes:changed', + 'config:true', + 'all:configuration:changed', + ].sort()) + bus.dispose() + expect(bus.hasListeners('message')).toBe(false) + }) + + it('owns duplicate registrations independently and removes once subscriptions before reentrant delivery', () => { + const bus = new EventBus(fail) + const seen: string[] = [] + const listener = () => seen.push('same') + const first = bus.on('message', listener) + const second = bus.on('message', listener) + first.dispose() + first.dispose() + bus.emit({ type: 'message', data: 'first' }) + second.dispose() + bus.on('message', () => { + seen.push('once') + bus.emit({ type: 'message', data: 'nested' }) + }, { once: true }) + bus.emit({ type: 'message', data: 'second' }) + expect(seen).toEqual(['same', 'once']) + expect(bus.hasListeners('message')).toBe(false) + }) + + it('snapshots deliveries, skips removed listeners and releases abort-bound or disposed subscriptions', () => { + const bus = new EventBus(fail) + const seen: string[] = [] + const abort = new AbortController() + bus.on('message', () => { + bus.on('message', () => seen.push('next')) + abort.abort() + }, { once: true }) + bus.on('message', () => seen.push('removed'), { signal: abort.signal }) + bus.emit({ type: 'message', data: 'first' }) + expect(seen).toEqual([]) + bus.on('message', () => seen.push('already-aborted'), { signal: abort.signal }) + bus.emit({ type: 'message', data: 'next' }) + expect(seen).toEqual(['next']) + bus.dispose() + expect(bus.emit({ type: 'message', data: 'disposed' })).toBe(false) + expect(() => bus.on('message', () => {})).toThrow('EVENT_BUS_DISPOSED') + }) + + it('isolates sync and async observer failures', async () => { + const errors: string[] = [] + const bus = new EventBus((_error, event) => errors.push(event.type)) + const seen: string[] = [] + bus.on('message', () => { + throw new Error('private sync failure') + }) + bus.on('message', async () => { + throw new Error('private async failure') + }) + bus.on('message', () => seen.push('completed')) + expect(bus.emit({ type: 'message', data: 'notify' })).toBe(true) + await Promise.resolve() + expect(errors).toEqual(['message', 'message']) + }) + + it('composes typed arrays with overlap deduplication and a shared once, abort and disposal lifetime', async () => { + const bus = new EventBus(fail) + const seen: string[] = [] + const abort = new AbortController() + const group = bus.on(['configuration:*', 'configuration:changed', 'message'], (event) => { + if (event.type === 'configuration:changed') { + expectTypeOf(event.data).toEqualTypeOf<{ enabled: boolean }>() + seen.push(`config:${event.data.enabled}`) + } + else { + expectTypeOf(event.data).toEqualTypeOf() + seen.push(event.data) + } + }, { signal: abort.signal }) + bus.emit({ type: 'configuration:changed', data: { enabled: true } }) + await bus.emit({ type: 'message', data: 'async' }) + abort.abort() + group.dispose() + expect(bus.hasListeners('configuration:changed')).toBe(false) + bus.on(['message', 'workbench:**', 'workbench:visible'], () => { + seen.push('once') + bus.emit({ type: 'message', data: 'reentrant' }) + }, { once: true }) + await bus.emit({ type: 'workbench:visible', data: true }) + expect(seen).toEqual(['config:true', 'async', 'once']) + expect(bus.hasListeners('message')).toBe(false) + expect(bus.hasListeners('workbench:panes:changed')).toBe(false) + bus.on([], fail) + // @ts-expect-error Invalid patterns cannot partially install the group. + expect(() => bus.on(['message', 'bad:*:pattern'], fail)).toThrow('EVENT_PATTERN_INVALID') + expect(bus.hasListeners('message')).toBe(false) + }) +}) diff --git a/apps/buddy/shared/events/eventSnapshot.ts b/apps/buddy/shared/events/eventSnapshot.ts new file mode 100644 index 00000000..f9c80579 --- /dev/null +++ b/apps/buddy/shared/events/eventSnapshot.ts @@ -0,0 +1,28 @@ +import type { EventSnapshot } from './eventTypes' + +export function freezeEventSnapshot(value: Value): EventSnapshot { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) freezeEventSnapshot(child) + Object.freeze(value) + } + return value as EventSnapshot +} + +export function copyEventSnapshot(value: Value): EventSnapshot { + const copies = new WeakMap() + const copy = (input: unknown): unknown => { + if (input === null || typeof input !== 'object') + return input + const existing = copies.get(input) + if (existing) + return existing + if (!Array.isArray(input) && Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null) + throw new TypeError('EVENT_SNAPSHOT_REQUIRES_PLAIN_DATA') + const output: Record | unknown[] = Array.isArray(input) ? [] : Object.create(null) + copies.set(input, output) + for (const [key, child] of Object.entries(input)) + Object.defineProperty(output, key, { value: copy(child), enumerable: true, configurable: true, writable: true }) + return Object.freeze(output) + } + return copy(value) as EventSnapshot +} diff --git a/apps/buddy/shared/events/eventTypes.ts b/apps/buddy/shared/events/eventTypes.ts new file mode 100644 index 00000000..c06b18ad --- /dev/null +++ b/apps/buddy/shared/events/eventTypes.ts @@ -0,0 +1,16 @@ +export interface EventSubscription { dispose: () => void } +export interface EventSubscriptionOptions { signal?: AbortSignal, once?: boolean } +export type EventName = Extract +type NamespacePattern = Name extends `${infer Head}:${infer Tail}` ? `${Head}:*` | `${Head}:**` | `${Head}:${NamespacePattern}` : never +export type EventPattern = EventName | '*' | '**' | NamespacePattern> +type Matches = Pattern extends '**' ? true + : Pattern extends `${infer Prefix}:**` ? Name extends Prefix | `${Prefix}:${string}` ? true : false + : Pattern extends `${infer Prefix}:*` ? Name extends `${Prefix}:${infer Tail}` ? Tail extends `${string}:${string}` ? false : true : false + : Pattern extends '*' ? Name extends `${string}:${string}` ? false : true : Name extends Pattern ? true : false +export type EventMessage = Pattern extends unknown ? { + [Name in EventName]: Matches extends true ? Readonly<{ type: Name, data: Events[Name] }> : never +}[EventName] : never +export interface EventSubscriber { + on: >(patterns: Pattern | readonly Pattern[], listener: (event: EventMessage) => unknown, options?: EventSubscriptionOptions) => EventSubscription +} +export type EventSnapshot = Value extends (...args: never[]) => unknown ? Value : Value extends object ? { readonly [Key in keyof Value]: EventSnapshot } : Value diff --git a/apps/buddy/shared/extensions/extensionAgent.ts b/apps/buddy/shared/extensions/extensionAgent.ts new file mode 100644 index 00000000..7efdcba0 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionAgent.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import { extensionAgentMethodSchema } from './extensionAgentCapabilities' +import { extensionSettingIdSchema, extensionSettingKeySchema } from './extensionSettings' + +export const extensionAgentRpc = { + list: 'extensions.agent.list', + invoke: 'extensions.agent.invoke', + request: 'extensions.agent.request', + changed: 'extensions.agent.changed', +} as const +export const extensionToolSchema = z.object({ + id: extensionSettingIdSchema, + title: z.string().min(1).max(100), + description: z.string().min(1).max(4000), + parameters: z.object({ + type: z.literal('object'), + properties: z.record(z.string().max(80).regex(/^[a-z][a-zA-Z0-9]*$/), z.object({ type: z.enum(['string', 'number', 'boolean']), description: z.string().max(1000).optional() }).strict()).refine(value => Object.keys(value).length <= 16), + required: z.array(z.string()).max(16).default([]), + additionalProperties: z.literal(false).default(false), + }).strict().refine(value => value.required.every(key => Object.hasOwn(value.properties, key))), +}).strict() +export const extensionAgentSchema = z.object({ + enabledWhen: extensionSettingKeySchema.optional(), + instructions: z.string().max(8000).default(''), + tools: z.array(extensionToolSchema).min(1).max(16), +}).strict() +export const extensionAgentDescriptorSchema = z.object({ + id: z.string(), + name: z.string(), + revision: z.string(), + configurationRevision: z.string(), + agent: extensionAgentSchema, +}).strict() +export type ExtensionAgentDescriptor = z.infer +export const extensionAgentInvocationSchema = z.object({ + extensionId: z.string(), + revision: z.string(), + configurationRevision: z.string(), + invocationId: z.string().uuid(), + tool: z.string().max(180), + input: z.record(z.string(), z.union([z.string().max(32768), z.number().finite(), z.boolean()])), +}).strict() +export type ExtensionAgentInvocation = z.infer +export const extensionAgentRequestSchema = z.object({ + invocationId: z.string().uuid(), + method: extensionAgentMethodSchema, + params: z.json(), +}).strict() diff --git a/apps/buddy/shared/extensions/extensionAgentCapabilities.ts b/apps/buddy/shared/extensions/extensionAgentCapabilities.ts new file mode 100644 index 00000000..e3499043 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionAgentCapabilities.ts @@ -0,0 +1,22 @@ +import type { ExtensionManifest } from './extensionManifest' +import { z } from 'zod' +import { extensionModelSelectionSchema } from './extensionSettings' + +interface RequestLimits { calls: number, concurrent: number, error: string } +function capability(input: I, output: O, permitted: (permissions: ExtensionManifest['permissions']) => boolean, limits: RequestLimits | null = null) { + return { input, output, permitted, limits } +} + +export const extensionAgentCapabilities = { + 'task.get': capability(z.null(), z.object({ id: z.string(), title: z.string().nullable(), titleSource: z.enum(['manual', 'fallback', 'generated']), titleRevision: z.number().int().nonnegative() }).strict(), permissions => permissions.tasks !== 'none'), + 'task.rename': capability(z.object({ title: z.string().trim().min(1).max(80).refine(value => [...value].every(character => character.charCodeAt(0) >= 32)), expectedRevision: z.number().int().nonnegative() }).strict(), z.object({ applied: z.boolean() }).strict(), permissions => permissions.tasks === 'title'), + 'models.generateText': capability(z.object({ + model: extensionModelSelectionSchema.nullable().default(null), + system: z.string().max(8192).default(''), + prompt: z.string().min(1).max(32768), + maxTokens: z.number().int().min(16).max(4096).default(256), + }).strict(), z.object({ text: z.string(), model: extensionModelSelectionSchema }).strict(), permissions => permissions.models, { calls: 4, concurrent: 1, error: 'EXTENSION_MODEL_LIMIT' }), +} as const + +export type ExtensionAgentMethod = keyof typeof extensionAgentCapabilities +export const extensionAgentMethodSchema = z.enum(Object.keys(extensionAgentCapabilities) as ExtensionAgentMethod[]) diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 46b7706f..512646ae 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -4,11 +4,13 @@ import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbe import type { ExtensionCatalogSnapshot } from './extensionCatalog' import type { ExtensionInstallation } from './extensionInstallation' import type { ExtensionManifest } from './extensionManifest' +import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from './extensionSettings' import { z } from 'zod' import { spaceFileTargetSchema } from '../spaces/spaceFileApi' import { workbenchPanesSchema } from '../workbench/workbenchInteraction' import { workbenchMenuSchema } from '../workbench/workbenchUi' import { extensionIdSchema } from './extensionManifest' +import { extensionConfigurationSchema } from './extensionSettings' export const EXTENSION_IPC = { request: 'lexora:extensions:request', @@ -83,6 +85,7 @@ export interface ExtensionReview { } export type ExtensionWorkbenchEvent = | { kind: 'cancel', requestId: string } + | { kind: 'clear-data', requestId: string, extensionId: string } | { kind: 'open', requestId: string, extensionId: string, generation: string, viewType: string, resource: ExtensionResource | null, state: JsonValue, stateVersion: number } | { kind: 'state', requestId: string, viewId: string, generation: string, token: string, state: JsonValue, stateVersion: number } | { kind: 'interaction', requestId: string, extensionId: string, generation: string, interactionId: string, title: string | null } @@ -95,6 +98,9 @@ export type ExtensionWorkbenchEvent export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('list') }).strict(), + z.object({ action: z.literal('configuration'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('configurationSnapshot'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('configure'), id: extensionIdSchema, patch: extensionConfigurationSchema }).strict(), z.object({ action: z.literal('installations') }).strict(), z.object({ action: z.literal('catalog'), refresh: z.boolean().default(false) }).strict(), z.object({ action: z.literal('reviewCatalog'), id: extensionIdSchema, version: z.string().max(80) }).strict(), @@ -104,7 +110,7 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('cancelInstall'), token: z.string().uuid() }).strict(), z.object({ action: z.literal('enable'), id: extensionIdSchema, enabled: z.boolean() }).strict(), z.object({ action: z.literal('restart'), id: extensionIdSchema }).strict(), - z.object({ action: z.literal('uninstall'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('uninstall'), id: extensionIdSchema, clearData: z.boolean().default(false) }).strict(), z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(), @@ -119,6 +125,9 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ export type ExtensionManagementRequest = z.infer export interface ExtensionApi { list: () => Promise + configuration: (id: string) => Promise + configurationSnapshot: (id: string) => Promise + configure: (id: string, patch: ExtensionConfiguration) => Promise installations: () => Promise catalog: (refresh?: boolean) => Promise reviewCatalog: (id: string, version: string) => Promise @@ -128,7 +137,7 @@ export interface ExtensionApi { cancelInstall: (token: string) => Promise enable: (id: string, enabled: boolean) => Promise restart: (id: string) => Promise - uninstall: (id: string) => Promise + uninstall: (id: string, options?: { clearData?: boolean }) => Promise devtools: (id: string) => Promise revokeResources: (id: string) => Promise execute: (id: string, command: string, resource: import('../spaces/spaceFileApi').SpaceFileTarget | null) => Promise diff --git a/apps/buddy/shared/extensions/extensionEvents.ts b/apps/buddy/shared/extensions/extensionEvents.ts new file mode 100644 index 00000000..f73f5e69 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionEvents.ts @@ -0,0 +1,10 @@ +import type { EventMessage } from '../events/eventTypes' +import type { ComposerEvents, ControlEvents, InteractionEvents, WorkbenchContextEvents, WorkbenchPaneEvents } from '../workbench/workbenchEvents' +import type { ConfigurationEvents } from './extensionSettings' +import type { ViewGeometryEvents, ViewMessageEvents, ViewStateEvents } from './extensionViewEvents' +import type { ExtensionViewUpdate } from './extensionViewProjection' + +export interface ExtensionHostEvents extends ConfigurationEvents, WorkbenchPaneEvents {} +export interface ExtensionViewEvents extends ViewStateEvents, ViewGeometryEvents, ViewMessageEvents, WorkbenchContextEvents, ControlEvents, InteractionEvents, ComposerEvents {} +export type ExtensionViewNotification = EventMessage +export type ExtensionViewHostMessage = ExtensionViewUpdate | { ping: string } | { id: string, ok: boolean, value: unknown } diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 8c8544cb..7b28f3ff 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -3,7 +3,9 @@ import { z } from 'zod' import { workbenchSlashSchema } from '../workbench/workbenchCommand' import { workbenchConditionSchema } from '../workbench/workbenchContext' import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi' +import { extensionAgentSchema } from './extensionAgent' import { extensionAuthorSchema } from './extensionIdentity' +import { extensionSettingsGroups, extensionSettingsModules, extensionSettingsSchema, validateExtensionSetting } from './extensionSettings' export const EXTENSION_API_VERSION = 3 export const EXTENSION_PROTOCOL = 'lexora-extension' @@ -53,6 +55,9 @@ export const extensionPlacementSchema = z.discriminatedUnion('kind', [ }) export type ExtensionPlacement = z.infer export const extensionPermissionsSchema = z.object({ + agent: z.boolean().default(false), + models: z.boolean().default(false), + tasks: z.enum(['none', 'read', 'title']).default('none'), windowEffects: z.boolean().default(false), controls: z.array(workbenchControlSchema).max(workbenchControlSchema.options.length).default([]), notifications: z.boolean().default(false), @@ -84,6 +89,8 @@ export const extensionManifestSchema = z.object({ dependencies: z.record(extensionIdSchema, z.string().max(100).refine(value => validRange(value) !== null)).default({}), permissions: extensionPermissionsSchema.prefault({}), contributes: z.object({ + agent: extensionAgentSchema.optional(), + settings: extensionSettingsSchema.prefault({}), commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), slash: workbenchSlashSchema.optional(), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), menus: z.array(z.object({ id: contributionId, command: contributionId, target: workbenchMenuSchema, order: z.number().int().min(-1000).max(1000).default(0), when: workbenchConditionSchema.optional() }).strict()).max(32).default([]), views: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), entry: extensionPathSchema, stateVersion: z.number().int().min(1).max(10000).default(1), resource: z.enum(['selected-file', 'none']).default('selected-file'), location: z.enum(['context', 'page', 'window-overlay']).default('context'), when: workbenchConditionSchema.optional() }).strict()).max(16).default([]), @@ -92,11 +99,34 @@ export const extensionManifestSchema = z.object({ }).strict(), }).strict().superRefine((manifest, context) => { const ids = new Set() - for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements, ...manifest.contributes.menus]) { + const settings = manifest.contributes.settings + const agent = manifest.contributes.agent + for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements, ...manifest.contributes.menus, ...settings.modules, ...settings.groups, ...settings.items, ...agent?.tools ?? []]) { if (!contribution.id.startsWith(`${manifest.id}.`) || ids.has(contribution.id)) context.addIssue({ code: 'custom', message: 'Contribution IDs must be unique and owned by the extension' }) ids.add(contribution.id) } + const issue = (message: string) => context.addIssue({ code: 'custom', message }) + if (manifest.apiVersion < 3 && (agent || settings.modules.length || settings.groups.length || settings.items.length || manifest.permissions.agent || manifest.permissions.models || manifest.permissions.tasks !== 'none')) + issue('Agent capabilities and settings require API 3') + if (agent && (!manifest.entry || !manifest.permissions.agent)) + issue('Agent contributions require an entry and agent permission') + if (agent?.enabledWhen && !settings.items.some(item => item.key === agent.enabledWhen && item.type === 'boolean')) + issue('Agent enabledWhen must reference a boolean setting') + const keys = new Set() + for (const group of settings.groups) { + if (!settings.modules.some(module => module.id === group.module) && !extensionSettingsModules.includes(group.module)) + issue('Setting groups must reference an owned or built-in module') + } + for (const item of settings.items) { + if (keys.has(item.key) || (!settings.groups.some(group => group.id === item.group) && !Object.hasOwn(extensionSettingsGroups, item.group))) + issue('Setting keys must be unique and groups must be owned or built-in') + keys.add(item.key) + try { + validateExtensionSetting(item, item.default) + } + catch { issue('Invalid setting default') } + } if (manifest.contributes.commands.length && !manifest.entry) context.addIssue({ code: 'custom', message: 'Commands require an extension entry' }) if (manifest.id in manifest.dependencies) @@ -152,6 +182,9 @@ export function extensionCompatible(manifest: ExtensionManifest, version: string export function addedExtensionPermissions(previous: ExtensionPermissions | undefined, next: ExtensionPermissions): string[] { return [ + ...(next.agent && !previous?.agent ? ['agent'] : []), + ...(next.models && !previous?.models ? ['models'] : []), + ...(next.tasks !== 'none' && next.tasks !== previous?.tasks && previous?.tasks !== 'title' ? [`tasks:${next.tasks}`] : []), ...(next.windowEffects && !previous?.windowEffects ? ['windowEffects'] : []), ...next.controls.filter(target => !previous?.controls?.includes(target)).map(target => `controls:${target}`), ...(next.notifications && !previous?.notifications ? ['notifications'] : []), diff --git a/apps/buddy/shared/extensions/extensionSettings.ts b/apps/buddy/shared/extensions/extensionSettings.ts new file mode 100644 index 00000000..3a13c176 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionSettings.ts @@ -0,0 +1,66 @@ +import type { EventSnapshot } from '../events/eventTypes' +import { z } from 'zod' +import { builtinSettingsModuleIds, publicSettingsGroups } from '../settings/settingsCatalog' + +export const extensionSettingIdSchema = z.string().max(180).regex(/^[a-z][a-z0-9.-]+$/) +export const extensionSettingKeySchema = z.string().max(80).regex(/^[a-z][a-zA-Z0-9]*$/) +export const extensionModelSelectionSchema = z.object({ providerId: z.string().min(1).max(200), modelId: z.string().min(1).max(200) }).strict() +export const extensionSettingValueSchema = z.union([z.boolean(), z.string().max(8192), z.number().finite(), extensionModelSelectionSchema, z.null()]) +export const extensionConfigurationSchema = z.record(extensionSettingKeySchema, extensionSettingValueSchema) +export type ExtensionConfiguration = z.infer +export type ExtensionSettingValue = z.infer +export interface ConfigurationEvents { + 'configuration:changed': { readonly configuration: EventSnapshot, readonly changedKeys: readonly string[] } +} +export const extensionConfigurationAppliedSchema = z.object({ + operationId: z.uuid(), + generation: z.uuid(), + configurationRevision: z.string().regex(/^[a-f0-9]{64}$/), + applied: z.boolean(), +}).strict() +export const extensionConfigurationSnapshotSchema = z.object({ values: extensionConfigurationSchema, invalidKeys: z.array(extensionSettingKeySchema).max(64) }).strict() +export type ExtensionConfigurationSnapshot = z.infer +export const extensionSettingsModules = builtinSettingsModuleIds +export const extensionSettingsGroups = publicSettingsGroups +const itemBase = { id: extensionSettingIdSchema, key: extensionSettingKeySchema, group: extensionSettingIdSchema, title: z.string().min(1).max(100), description: z.string().max(500).default(''), order: z.number().int().min(-1000).max(1000).default(0) } +export const extensionSettingItemSchema = z.discriminatedUnion('type', [ + z.object({ ...itemBase, type: z.literal('boolean'), default: z.boolean() }).strict(), + z.object({ ...itemBase, type: z.literal('string'), default: z.string().max(8192) }).strict(), + z.object({ ...itemBase, type: z.literal('number'), default: z.number().finite(), min: z.number().finite().optional(), max: z.number().finite().optional() }).strict(), + z.object({ ...itemBase, type: z.literal('select'), default: z.string(), options: z.array(z.object({ label: z.string().min(1).max(100), value: z.string().max(200) }).strict()).min(1).max(64) }).strict(), + z.object({ ...itemBase, type: z.literal('model'), default: extensionModelSelectionSchema.nullable().default(null) }).strict(), +]) +export type ExtensionSettingItem = z.infer +export const extensionSettingsSchema = z.object({ + modules: z.array(z.object({ id: extensionSettingIdSchema, title: z.string().min(1).max(40), order: z.number().int().min(-1000).max(1000).default(0) }).strict()).max(8).default([]), + groups: z.array(z.object({ id: extensionSettingIdSchema, module: extensionSettingIdSchema, title: z.string().min(1).max(100), order: z.number().int().min(-1000).max(1000).default(0) }).strict()).max(32).default([]), + items: z.array(extensionSettingItemSchema).max(64).default([]), +}).strict() + +export function validateExtensionSetting(item: ExtensionSettingItem, value: unknown): ExtensionSettingValue { + const parsed = extensionSettingValueSchema.parse(value) + const valid = item.type === 'model' + ? parsed === null || typeof parsed === 'object' + : item.type === 'select' + ? item.options.some(option => option.value === parsed) + : item.type === 'number' + ? typeof parsed === 'number' && (item.min === undefined || parsed >= item.min) && (item.max === undefined || parsed <= item.max) + : item.type === 'boolean' ? typeof parsed === 'boolean' : typeof parsed === 'string' + if (!valid) + throw new Error('EXTENSION_CONFIGURATION_INVALID') + return parsed +} + +export function resolveExtensionConfiguration(items: readonly ExtensionSettingItem[], stored: ExtensionConfiguration): ExtensionConfigurationSnapshot { + const values: ExtensionConfiguration = {} + const invalidKeys: string[] = [] + for (const item of items) { + const value = Object.hasOwn(stored, item.key) ? stored[item.key]! : item.default + values[item.key] = value + try { + validateExtensionSetting(item, value) + } + catch { invalidKeys.push(item.key) } + } + return { values, invalidKeys } +} diff --git a/apps/buddy/shared/extensions/extensionViewEvents.ts b/apps/buddy/shared/extensions/extensionViewEvents.ts new file mode 100644 index 00000000..31789766 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionViewEvents.ts @@ -0,0 +1,20 @@ +import type { EventSnapshot } from '../events/eventTypes' +import type { ReadonlyJsonValue } from '../workbench/workbenchState' +import type { AnchorGeometry, MountGeometry } from '../workbench/workbenchUi' + +export interface ViewEnvironment { + readonly language: string + readonly colorScheme: 'light' | 'dark' + readonly colors: Readonly> +} +export interface ViewStateEvents { + 'view:visibility:changed': { readonly visible: boolean } + 'view:environment:changed': { readonly environment: ViewEnvironment } +} +export interface ViewGeometryEvents { + 'view:mount:changed': { readonly mount: EventSnapshot } + 'view:anchor:changed': { readonly anchor: EventSnapshot } +} +export interface ViewMessageEvents { + 'view:message:received': { readonly message: ReadonlyJsonValue } +} diff --git a/apps/buddy/shared/extensions/extensionViewProjection.ts b/apps/buddy/shared/extensions/extensionViewProjection.ts new file mode 100644 index 00000000..75bf2cbf --- /dev/null +++ b/apps/buddy/shared/extensions/extensionViewProjection.ts @@ -0,0 +1,76 @@ +import type { EventSnapshot } from '../events/eventTypes' +import type { WorkbenchContextSnapshot } from '../workbench/workbenchContext' +import type { AnchorGeometry, ControlSnapshot, MountGeometry } from '../workbench/workbenchUi' +import type { ExtensionViewNotification } from './extensionEvents' +import type { ViewEnvironment } from './extensionViewEvents' +import { copyEventSnapshot } from '../events/eventSnapshot' + +export interface ExtensionViewSnapshot { + readonly workbench: WorkbenchContextSnapshot + readonly environment: ViewEnvironment + readonly visible: boolean + readonly mount: EventSnapshot | null + readonly anchor: EventSnapshot | null + readonly control: EventSnapshot | null +} +export interface ExtensionViewPolicy { + readonly decoration: boolean + readonly control: boolean + readonly interaction: boolean +} +export interface ExtensionViewCursor { + readonly streamId: string + readonly sequence: number +} +export interface ExtensionViewUpdate extends ExtensionViewCursor { + readonly event: ExtensionViewNotification +} +export interface ExtensionViewSynchronization extends ExtensionViewCursor { + readonly snapshot: ExtensionViewSnapshot +} + +export function projectExtensionViewEvent(snapshot: ExtensionViewSnapshot, input: ExtensionViewNotification, policy: ExtensionViewPolicy): { snapshot: ExtensionViewSnapshot, event: ExtensionViewNotification } | null { + const event = copyEventSnapshot(input) as ExtensionViewNotification + let update: Partial = {} + switch (event.type) { + case 'workbench:context:changed': + update = { workbench: event.data.context } + break + case 'view:environment:changed': + update = { environment: event.data.environment } + break + case 'view:visibility:changed': + update = { visible: event.data.visible } + break + case 'view:mount:changed': + update = { mount: event.data.mount } + break + case 'view:anchor:changed': + if (!policy.decoration) + return null + update = { anchor: event.data.anchor } + break + case 'control:changed': + if (!policy.control) + return null + update = { control: event.data.control } + break + case 'composer:input:received': + if (!policy.decoration) + return null + break + case 'interaction:activated': + if (!policy.interaction) + return null + break + case 'view:message:received': break + default: return null + } + if (Object.keys(update).length && Object.entries(update).every(([key, value]) => JSON.stringify(snapshot[key as keyof ExtensionViewSnapshot]) === JSON.stringify(value))) + return null + return { snapshot: Object.freeze({ ...snapshot, ...update }), event } +} + +export function ownExtensionViewSnapshot(snapshot: ExtensionViewSnapshot, policy: ExtensionViewPolicy): ExtensionViewSnapshot { + return copyEventSnapshot({ ...snapshot, anchor: policy.decoration ? snapshot.anchor : null, control: policy.control ? snapshot.control : null }) +} diff --git a/apps/buddy/shared/lifecycle/ServiceHost.ts b/apps/buddy/shared/lifecycle/ServiceHost.ts index ac68a7c9..83500541 100644 --- a/apps/buddy/shared/lifecycle/ServiceHost.ts +++ b/apps/buddy/shared/lifecycle/ServiceHost.ts @@ -1,31 +1,33 @@ -import type { ComponentEvent, OperationEvent } from '../observability/ApplicationEvents' -import { readDiagnosticError } from '../diagnostics/applicationDiagnostic' -import { ApplicationEvents } from '../observability/ApplicationEvents' +import type { LifecycleComponent, ServiceLifecycleReader } from './serviceLifecycle' +import { readLifecycleFailure } from './lifecycleFailure' +import { lifecycleComponentSchema } from './serviceLifecycle' +import { ServiceLifecycleSource } from './ServiceLifecycleSource' type Cleanup = () => void | Promise export interface ServiceScope { - events: ApplicationEvents defer: (cleanup: Cleanup) => void } interface Component { kind: 'service' | 'operation' id: string - events: ApplicationEvents + operationId: string cleanups: Cleanup[] status: 'starting' | 'ready' | 'failed' | 'stopped' pending: Promise | null } export class ServiceHost { - readonly events: ApplicationEvents + readonly lifecycle: ServiceLifecycleReader + readonly #lifecycle: ServiceLifecycleSource readonly #components = new Map() #stopping = false #stopPromise: Promise | null = null - constructor(events = new ApplicationEvents()) { - this.events = events + constructor(lifecycle = new ServiceLifecycleSource()) { + this.#lifecycle = lifecycle + this.lifecycle = lifecycle.reader } start(id: string, initialize: (scope: ServiceScope) => T | Promise, dependencies: readonly string[] = []): Promise { @@ -41,6 +43,7 @@ export class ServiceHost { return Promise.reject(new Error('Service host is stopping')) if (this.#components.has(id)) return Promise.reject(new Error(`Component already registered: ${id}`)) + lifecycleComponentSchema.shape.component.parse(id) for (const dependency of dependencies) { if (this.#components.get(dependency)?.status !== 'ready') return Promise.reject(new Error(`Component dependency is not ready: ${dependency}`)) @@ -48,36 +51,39 @@ export class ServiceHost { const component: Component = { kind, id, - events: this.events.scope({ component: id, operationId: crypto.randomUUID() }), + operationId: crypto.randomUUID(), cleanups: [], status: 'starting', pending: null, } - this.#components.set(id, component) - if (kind === 'service') - this.#publish(component, 'component.registered') - this.#publish(component, kind === 'service' ? 'component.starting' : 'startup.step.started') const startedAt = performance.now() const pending = Promise.resolve().then(() => initialize({ - events: component.events, defer: cleanup => component.cleanups.push(cleanup), })).then((value) => { component.status = 'ready' if (kind === 'operation' || !this.#stopping) - this.#publish(component, kind === 'service' ? 'component.ready' : 'startup.step.completed', startedAt) + this.#publish(component, 'ready', startedAt) return value }, (error: unknown) => { component.status = 'failed' - this.#publish(component, kind === 'service' ? 'component.start_failed' : 'startup.step.failed', startedAt, error) + this.#publish(component, 'start_failed', startedAt, error) throw error }) component.pending = pending + this.#components.set(id, component) + if (kind === 'service') + this.#publish(component, 'registered') + this.#publish(component, 'starting') return pending } stop(): Promise { + if (this.#stopPromise) + return this.#stopPromise this.#stopping = true - return this.#stopPromise ??= this.#stop() + this.#stopPromise = Promise.resolve().then(() => this.#stop()) + this.#lifecycle.stopping() + return this.#stopPromise } async #stop(): Promise { @@ -87,7 +93,7 @@ export class ServiceHost { if (component.kind === 'operation') continue const startedAt = performance.now() - this.#publish(component, 'component.stopping') + this.#publish(component, 'stopping') const componentFailures: unknown[] = [] for (const cleanup of component.cleanups.splice(0).reverse()) { try { @@ -100,23 +106,25 @@ export class ServiceHost { component.status = componentFailures.length ? 'failed' : 'stopped' if (componentFailures.length) { const error = new AggregateError(componentFailures, 'Component cleanup failed') - this.#publish(component, 'component.stop_failed', startedAt, error) + this.#publish(component, 'stop_failed', startedAt, error) failures.push(error) } else { - this.#publish(component, 'component.stopped', startedAt) + this.#publish(component, 'stopped', startedAt) } } if (failures.length) throw new AggregateError(failures, 'Service host cleanup failed') } - #publish(component: Component, event: ComponentEvent | OperationEvent, startedAt?: number, error?: unknown): void { - component.events.publish({ - event, - level: event.endsWith('failed') ? 'error' : 'info', + #publish(component: Component, status: LifecycleComponent['status'], startedAt?: number, error?: unknown): void { + this.#lifecycle.update({ + component: component.id, + kind: component.kind, + operationId: component.operationId, + status, ...(startedAt === undefined ? {} : { durationMs: Math.round(performance.now() - startedAt) }), - ...(error === undefined ? {} : readDiagnosticError(error)), + ...(error === undefined ? {} : { failure: readLifecycleFailure(error) }), }) } } diff --git a/apps/buddy/shared/lifecycle/ServiceLifecycleSource.ts b/apps/buddy/shared/lifecycle/ServiceLifecycleSource.ts new file mode 100644 index 00000000..6411bb5d --- /dev/null +++ b/apps/buddy/shared/lifecycle/ServiceLifecycleSource.ts @@ -0,0 +1,37 @@ +import type { ListenerErrorHandler } from '../events/Emitter' +import type { LifecycleComponent, ServiceLifecycleChange, ServiceLifecycleReader, ServiceLifecycleSnapshot } from './serviceLifecycle' +import { Emitter } from '../events/Emitter' +import { copyEventSnapshot } from '../events/eventSnapshot' + +export class ServiceLifecycleSource { + readonly #changes: Emitter + readonly #components = new Map() + readonly reader: ServiceLifecycleReader + #snapshot: ServiceLifecycleSnapshot = copyEventSnapshot({ sourceId: crypto.randomUUID(), revision: 0, stopping: false, components: [] }) + + constructor(onListenerError: ListenerErrorHandler = () => {}) { + this.#changes = new Emitter(onListenerError) + const snapshot = () => this.#snapshot + this.reader = Object.freeze({ + get snapshot() { return snapshot() }, + onDidChange: this.#changes.event, + }) + } + + update(component: LifecycleComponent): void { + this.#components.set(component.component, copyEventSnapshot(component)) + this.#publish(component) + } + + stopping(): void { + if (this.#snapshot.stopping) + return + this.#snapshot = { ...this.#snapshot, stopping: true } + this.#publish() + } + + #publish(component?: LifecycleComponent): void { + this.#snapshot = copyEventSnapshot({ ...this.#snapshot, revision: this.#snapshot.revision + 1, components: [...this.#components.values()] }) + this.#changes.fire(copyEventSnapshot({ snapshot: this.#snapshot, ...(component ? { component } : {}) })) + } +} diff --git a/apps/buddy/shared/lifecycle/__tests__/ServiceHost.spec.ts b/apps/buddy/shared/lifecycle/__tests__/ServiceHost.spec.ts index abeb583a..dd1ff374 100644 --- a/apps/buddy/shared/lifecycle/__tests__/ServiceHost.spec.ts +++ b/apps/buddy/shared/lifecycle/__tests__/ServiceHost.spec.ts @@ -1,6 +1,7 @@ import type { ApplicationDiagnostic } from '../../diagnostics/applicationDiagnostic' import { describe, expect, it } from 'vitest' import { ApplicationEvents } from '../../observability/ApplicationEvents' +import { observeLifecycleDiagnostics } from '../../observability/lifecycleDiagnostics' import { ServiceHost } from '../ServiceHost' function fixture() { @@ -9,7 +10,9 @@ function fixture() { events.subscribe((event) => { records.push(event) }) - return { records, events, host: new ServiceHost(events) } + const host = new ServiceHost() + const stopDiagnostics = observeLifecycleDiagnostics(host.lifecycle, events) + return { records, events, host, stopDiagnostics } } describe('managed component lifecycle', () => { @@ -73,4 +76,41 @@ describe('managed component lifecycle', () => { expect(records.some(record => record.event === 'component.ready')).toBe(false) expect(records.at(-1)?.event).toBe('component.stopped') }) + + it('keeps an immutable current snapshot after diagnostic collection stops', async () => { + const { host, stopDiagnostics, records } = fixture() + await host.start('desktop.feature', () => true) + stopDiagnostics() + const before = host.lifecycle.snapshot + expect(() => Object.assign(before.components[0]!, { status: 'failed' })).toThrow() + await host.stop() + expect(host.lifecycle.snapshot.components[0]?.status).toBe('stopped') + expect(before.components[0]?.status).toBe('ready') + expect(records.at(-1)?.event).toBe('component.ready') + }) + + it('isolates observers and waits for resources when an observer requests stop during registration', async () => { + const host = new ServiceHost() + let stopping: Promise | undefined + let cleanups = 0 + const states: string[] = [] + host.lifecycle.onDidChange(() => { + throw new Error('observer failed') + }) + host.lifecycle.onDidChange(({ component }) => { + if (component) + states.push(component.status) + if (component?.status === 'registered') + stopping = host.stop() + }) + await host.start('desktop.feature', ({ defer }) => { + defer(() => { + cleanups += 1 + }) + }) + await stopping + expect(cleanups).toBe(1) + expect(states).toEqual(['registered', 'starting', 'stopping', 'stopped']) + expect(host.lifecycle.snapshot.components[0]?.status).toBe('stopped') + }) }) diff --git a/apps/buddy/shared/lifecycle/__tests__/serviceLifecycle.spec.ts b/apps/buddy/shared/lifecycle/__tests__/serviceLifecycle.spec.ts new file mode 100644 index 00000000..e1f673c1 --- /dev/null +++ b/apps/buddy/shared/lifecycle/__tests__/serviceLifecycle.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { rendererLifecycleReportSchema, serviceLifecycleChangeSchema } from '../serviceLifecycle' +import { ServiceLifecycleSource } from '../ServiceLifecycleSource' + +describe('lifecycle transport snapshots', () => { + it('accepts a complete current snapshot and rejects mismatched changes, duplicate components and diagnostic payloads', () => { + const source = new ServiceLifecycleSource() + const component = { component: 'renderer', kind: 'service', operationId: 'renderer-1', status: 'ready' } as const + source.update(component) + const change = { snapshot: source.reader.snapshot, component } + expect(rendererLifecycleReportSchema.parse({ generation: 'runtime-1', change }).change.snapshot.revision).toBe(1) + expect(serviceLifecycleChangeSchema.safeParse({ ...change, component: { ...component, status: 'start_failed' } }).success).toBe(false) + expect(serviceLifecycleChangeSchema.safeParse({ ...change, snapshot: { ...change.snapshot, components: [component, component] } }).success).toBe(false) + expect(rendererLifecycleReportSchema.safeParse({ generation: 'runtime-1', change, payload: { content: 'fixture-private' } }).success).toBe(false) + expect(serviceLifecycleChangeSchema.safeParse({ event: 'component.ready', level: 'info', component: 'renderer' }).success).toBe(false) + }) +}) diff --git a/apps/buddy/shared/lifecycle/lifecycleFailure.ts b/apps/buddy/shared/lifecycle/lifecycleFailure.ts new file mode 100644 index 00000000..e8e8090d --- /dev/null +++ b/apps/buddy/shared/lifecycle/lifecycleFailure.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { desktopBootstrapFailureSchema } from '../diagnostics/desktopStartupDiagnostic' +import { networkStartupFailureSchema } from '../diagnostics/networkStartupFailure' +import { privateDirectoryErrorCodeSchema, privateDirectoryFailureSchema } from '../diagnostics/privateDirectoryFailure' +import { readLocalChatErrorCode } from '../runtime/localChatError' + +export const lifecycleFailureSchema = z.object({ + errorCode: z.string().regex(/^[A-Z][A-Z0-9_]{0,95}$/).optional(), + errorType: z.string().regex(/^[a-z]\w{0,95}$/i).optional(), + failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema, networkStartupFailureSchema]).optional(), +}).strict() + +export type LifecycleFailure = z.infer + +export function readLifecycleFailure(error: unknown): LifecycleFailure { + let errorCode: string | null = readLocalChatErrorCode(error) + let failure: LifecycleFailure['failure'] + const visited = new Set() + for (let current = error; current && typeof current === 'object' && visited.size < 8 && !visited.has(current); current = 'cause' in current ? current.cause : undefined) { + visited.add(current) + const code = 'code' in current ? current.code : undefined + const privateDirectoryCode = privateDirectoryErrorCodeSchema.safeParse(code) + if (!errorCode) { + if (privateDirectoryCode.success) + errorCode = privateDirectoryCode.data + else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'NETWORK_START_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code)) + errorCode = code + } + if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED' || code === 'NETWORK_START_FAILED')) { + const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : code === 'NETWORK_START_FAILED' ? networkStartupFailureSchema : desktopBootstrapFailureSchema + const parsed = schema.safeParse('failure' in current ? current.failure : undefined) + if (parsed.success) + failure = parsed.data + } + } + const errorType = lifecycleFailureSchema.shape.errorType.safeParse(error instanceof Error ? error.name : 'UnknownError') + return { + errorCode: errorCode ?? 'OPERATION_FAILED', + errorType: errorType.success ? errorType.data : 'UnknownError', + ...(failure ? { failure } : {}), + } +} diff --git a/apps/buddy/shared/lifecycle/runtimeLifecycle.ts b/apps/buddy/shared/lifecycle/runtimeLifecycle.ts new file mode 100644 index 00000000..1176bdd8 --- /dev/null +++ b/apps/buddy/shared/lifecycle/runtimeLifecycle.ts @@ -0,0 +1,23 @@ +import type { Event } from '../events/Emitter' +import type { LifecycleComponent, ServiceLifecycleSnapshot } from './serviceLifecycle' + +export type RuntimeLifecycleStatus = 'stopped' | 'starting' | 'ready' | 'restarting' | 'offline' | 'stopping' + +export interface RuntimeLifecycleSnapshot { + readonly revision: number + readonly generation: string | null + readonly status: RuntimeLifecycleStatus + readonly errorCode: string | null + readonly connection: LifecycleComponent | null + readonly services: ServiceLifecycleSnapshot | null +} + +export interface RuntimeLifecycleChange { + readonly snapshot: RuntimeLifecycleSnapshot + readonly connection?: LifecycleComponent +} + +export interface RuntimeLifecycleReader { + readonly lifecycleState: RuntimeLifecycleSnapshot + readonly onDidChangeLifecycle: Event +} diff --git a/apps/buddy/shared/lifecycle/serviceLifecycle.ts b/apps/buddy/shared/lifecycle/serviceLifecycle.ts new file mode 100644 index 00000000..1d5d3017 --- /dev/null +++ b/apps/buddy/shared/lifecycle/serviceLifecycle.ts @@ -0,0 +1,48 @@ +import type { Event } from '../events/Emitter' +import type { EventSnapshot } from '../events/eventTypes' +import { z } from 'zod' +import { lifecycleFailureSchema } from './lifecycleFailure' + +export const SERVICE_LIFECYCLE_METHOD = 'runtime.lifecycle' +export const lifecycleIdentitySchema = z.string().regex(/^\w[\w:.-]{0,191}$/) +export const lifecycleComponentSchema = z.object({ + component: z.string().regex(/^[a-z][a-z\d._-]{0,95}$/), + kind: z.enum(['service', 'operation']), + status: z.enum(['registered', 'starting', 'ready', 'start_failed', 'stopping', 'stopped', 'stop_failed']), + operationId: lifecycleIdentitySchema, + durationMs: z.number().finite().nonnegative().optional(), + failure: lifecycleFailureSchema.optional(), +}).strict().superRefine((component, context) => { + if (component.kind === 'operation' && !['starting', 'ready', 'start_failed'].includes(component.status)) + context.addIssue({ code: 'custom', message: 'Invalid operation lifecycle status' }) +}) +export const serviceLifecycleSnapshotSchema = z.object({ + sourceId: lifecycleIdentitySchema, + revision: z.number().int().nonnegative(), + stopping: z.boolean(), + components: z.array(lifecycleComponentSchema).max(256), +}).strict().superRefine((snapshot, context) => { + if (new Set(snapshot.components.map(component => component.component)).size !== snapshot.components.length) + context.addIssue({ code: 'custom', message: 'Duplicate lifecycle component' }) +}) +export const serviceLifecycleChangeSchema = z.object({ + snapshot: serviceLifecycleSnapshotSchema, + component: lifecycleComponentSchema.optional(), +}).strict().superRefine((change, context) => { + if (change.component && !change.snapshot.components.some(component => JSON.stringify(component) === JSON.stringify(change.component))) + context.addIssue({ code: 'custom', message: 'Lifecycle change does not match its snapshot' }) +}) +export const rendererLifecycleReportSchema = z.object({ + generation: lifecycleIdentitySchema, + change: serviceLifecycleChangeSchema, +}).strict() + +export type LifecycleComponent = EventSnapshot> +export type ServiceLifecycleSnapshot = EventSnapshot> +export type ServiceLifecycleChange = EventSnapshot> +export type RendererLifecycleReport = EventSnapshot> + +export interface ServiceLifecycleReader { + readonly snapshot: ServiceLifecycleSnapshot + readonly onDidChange: Event +} diff --git a/apps/buddy/shared/network/webApi.ts b/apps/buddy/shared/network/webApi.ts index 52655bea..aa76adb2 100644 --- a/apps/buddy/shared/network/webApi.ts +++ b/apps/buddy/shared/network/webApi.ts @@ -7,3 +7,5 @@ export const webRpc = { saveSettings: { method: 'web.saveSettings', input: webSettingsSchema, response: webSettingsSnapshotSchema }, saveCredential: { method: 'web.saveCredential', input: webCredentialInputSchema, response: webSettingsSnapshotSchema }, } as const satisfies Record + +export const webSettingsChanged = { method: 'web.settingsChanged', params: z.object({ revision: z.number().int().positive(), operationId: z.uuid() }).strict() } as const diff --git a/apps/buddy/shared/notifications/notificationApi.ts b/apps/buddy/shared/notifications/notificationApi.ts index 31d7d72e..7c6732a7 100644 --- a/apps/buddy/shared/notifications/notificationApi.ts +++ b/apps/buddy/shared/notifications/notificationApi.ts @@ -74,3 +74,6 @@ export const notificationsRpc = { markSeen: { method: 'notifications.markSeen', input: notificationsRequestSchemas.notificationRevision, response: notificationsResponseSchemas.notificationList }, markAllSeen: { method: 'notifications.markAllSeen', input: validationRequestSchemas.empty, response: notificationsResponseSchemas.notificationList }, } as const satisfies Record + +export const notificationsChanged = { method: 'notifications.changed', params: z.object({ revision: z.number().int().nonnegative() }).strict() } as const +export type NotificationsChanged = z.infer diff --git a/apps/buddy/shared/observability/lifecycleDiagnostics.ts b/apps/buddy/shared/observability/lifecycleDiagnostics.ts new file mode 100644 index 00000000..7de0d1bf --- /dev/null +++ b/apps/buddy/shared/observability/lifecycleDiagnostics.ts @@ -0,0 +1,25 @@ +import type { ApplicationDiagnostic } from '../diagnostics/applicationDiagnostic' +import type { LifecycleComponent, ServiceLifecycleReader } from '../lifecycle/serviceLifecycle' +import type { ApplicationEvents } from './ApplicationEvents' + +export function lifecycleDiagnostic(component: LifecycleComponent): ApplicationDiagnostic { + const operationStatus = { starting: 'started', ready: 'completed', start_failed: 'failed' } as const + return { + event: component.kind === 'service' + ? `component.${component.status}` + : `startup.step.${operationStatus[component.status as keyof typeof operationStatus]}`, + component: component.component, + operationId: component.operationId, + level: component.status.endsWith('failed') ? 'error' : 'info', + ...(component.durationMs === undefined ? {} : { durationMs: component.durationMs }), + ...component.failure, + } +} + +export function observeLifecycleDiagnostics(source: ServiceLifecycleReader, events: ApplicationEvents): () => void { + const subscription = source.onDidChange(({ component }) => { + if (component) + events.publish(lifecycleDiagnostic(component)) + }) + return () => subscription.dispose() +} diff --git a/apps/buddy/shared/permissions/sandboxLifecycle.ts b/apps/buddy/shared/permissions/sandboxLifecycle.ts new file mode 100644 index 00000000..04d39ae6 --- /dev/null +++ b/apps/buddy/shared/permissions/sandboxLifecycle.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { sandboxResultSchema } from './shellSandbox' + +export const sandboxLifecycleSnapshotSchema = z.object({ + phase: z.enum(['preparing', 'running', 'finished']), + started: z.boolean(), + waiting: z.number().int().nonnegative().max(1024), + cancellation: z.enum(['none', 'requested', 'timed-out']), + result: sandboxResultSchema.optional(), +}).strict() +export type SandboxLifecycleSnapshot = z.infer + +export const sandboxLifecycleEventSchema = z.object({ + revision: z.number().int().positive(), + kind: z.enum(['preparing', 'started', 'approval-wait', 'approval-settled', 'approval-resumed', 'cancel-requested', 'timed-out', 'settled', 'released']), + snapshot: sandboxLifecycleSnapshotSchema, +}).strict() +export type SandboxLifecycleEvent = z.infer + +export const sandboxLifecycleNotificationSchema = sandboxLifecycleEventSchema.extend({ requestId: z.uuid() }) diff --git a/apps/buddy/shared/providers/providerApi.ts b/apps/buddy/shared/providers/providerApi.ts index b573dd5b..1de092f5 100644 --- a/apps/buddy/shared/providers/providerApi.ts +++ b/apps/buddy/shared/providers/providerApi.ts @@ -308,6 +308,10 @@ export const providersRpc = { upsertCustom: { method: 'providers.upsertCustom', input: customProviderInputSchema, response: providersResponseSchemas.provider }, } as const satisfies Record +export const providerChangedSchema = z.object({ source: z.enum(['catalog', 'metadata', 'credentials']), revision: z.number().int().nonnegative() }).strict() +export type ProviderChanged = z.infer + export const providerNotifications = { + changed: { method: 'providers.changed', params: providerChangedSchema }, authChallenge: { method: 'providers.authChallenge', params: providerAuthChallengeSchema }, } as const satisfies Record diff --git a/apps/buddy/shared/settings/settingsCatalog.ts b/apps/buddy/shared/settings/settingsCatalog.ts new file mode 100644 index 00000000..481d699d --- /dev/null +++ b/apps/buddy/shared/settings/settingsCatalog.ts @@ -0,0 +1,25 @@ +export const builtinSettingsCatalog = { + general: { section: 'personal', requiresRuntime: false }, + appearance: { section: 'personal', requiresRuntime: false }, + notifications: { section: 'personal', requiresRuntime: false }, + pet: { section: 'personal', requiresRuntime: true }, + shortcuts: { section: 'personal', requiresRuntime: false }, + models: { section: 'ai', requiresRuntime: true }, + runtime: { section: 'ai', requiresRuntime: false }, + mcp: { section: 'ai', requiresRuntime: true }, + skills: { section: 'ai', requiresRuntime: true }, + usage: { section: 'ai', requiresRuntime: true }, + web: { section: 'integrations', requiresRuntime: true }, + browser: { section: 'integrations', requiresRuntime: false }, + proxy: { section: 'system', requiresRuntime: false }, + logs: { section: 'system', requiresRuntime: false }, + about: { section: 'system', requiresRuntime: false }, +} as const + +export type BuiltinSettingsCategory = keyof typeof builtinSettingsCatalog +export const builtinSettingsCategories = Object.keys(builtinSettingsCatalog) as BuiltinSettingsCategory[] +export const builtinSettingsModuleIds = builtinSettingsCategories.map(category => `settings.${category}`) +export const publicSettingsGroups: Readonly> = { + 'settings.general.general': 'settings.general', + 'settings.general.context-panel': 'settings.general', +} diff --git a/apps/buddy/shared/skills/skillApi.ts b/apps/buddy/shared/skills/skillApi.ts index 56c96643..1816f68b 100644 --- a/apps/buddy/shared/skills/skillApi.ts +++ b/apps/buddy/shared/skills/skillApi.ts @@ -124,7 +124,14 @@ export const skillsRpc = { remove: { method: 'skills.remove', input: skillsRequestSchemas.remove, response: skillsResponseSchemas.skills }, } as const satisfies Record -export const skillChangedSchema = scopeSchema +export const skillChangedSchema = scopeSchema.extend({ + sourceId: z.uuid(), + sequence: z.number().int().positive(), + generation: z.number().int().nonnegative(), + type: z.enum(['installation', 'catalog']), + mode: z.enum(['discovery', 'management']).optional(), +}).strict() +export type SkillChangeNotice = Readonly> export function isSkillAvailable(skill: Pick): boolean { return skill.status === 'available' || skill.status === 'manual_only' diff --git a/apps/buddy/shared/spaces/spaceApi.ts b/apps/buddy/shared/spaces/spaceApi.ts index 40b61108..0da53654 100644 --- a/apps/buddy/shared/spaces/spaceApi.ts +++ b/apps/buddy/shared/spaces/spaceApi.ts @@ -102,3 +102,12 @@ export const spacesRpc = { searchFiles: { method: 'spaces.searchFiles', input: spacesRequestSchemas.spaceFileSearch, response: spacesResponseSchemas.spaceFiles }, update: { method: 'spaces.update', input: spacesRequestSchemas.spaceUpdate.extend({ primaryDirectorySelectionVerified: z.boolean() }).strict(), response: spacesResponseSchemas.space }, } as const satisfies Record + +export const spaceChangeNoticeSchema = z.object({ + sourceId: z.uuid(), + revision: z.number().int().positive(), + spaceId: idSchema, + kind: z.enum(['created', 'updated', 'directory-granted', 'deleted']), +}).strict() +export type SpaceChangeNotice = z.infer +export const spaceChanged = { method: 'spaces.changed', params: spaceChangeNoticeSchema } as const diff --git a/apps/buddy/shared/workbench/workbenchContext.ts b/apps/buddy/shared/workbench/workbenchContext.ts index 6a9c0e65..2b5899e7 100644 --- a/apps/buddy/shared/workbench/workbenchContext.ts +++ b/apps/buddy/shared/workbench/workbenchContext.ts @@ -10,6 +10,6 @@ export interface WorkbenchContextSnapshot { readonly pages: readonly { readonly id: string, readonly title: string }[] } -export function matchesWorkbenchContext(condition: WorkbenchCondition | undefined, values: WorkbenchContextValues): boolean { +export function matchesWorkbenchContext(condition: Readonly> | undefined, values: WorkbenchContextValues): boolean { return !condition || Object.entries(condition).every(([key, expected]) => Object.hasOwn(values, key) && (Array.isArray(expected) ? expected.includes(values[key]!) : values[key] === expected)) } diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts index 2f861107..0fee1eae 100644 --- a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { builtinSettingsModuleIds, publicSettingsGroups } from '../settings/settingsCatalog' interface TargetDefinition { title: { 'zh-CN': string, 'en-US': string } @@ -43,6 +44,11 @@ export const workbenchDecorations = { } as const satisfies Record export const workbenchRuntimeCapabilities = { + 'events': { title: { 'zh-CN': '作用域事件订阅', 'en-US': 'Scoped events' }, scope: 'application', description: 'Lexora 0.9.1 context.events.on(pattern | readonly patterns[], listener, {once?,signal?}) subscribes to owned domain events as {type,data}. Exact names, suffix :* and :**, root * and **; overlapping patterns deliver once per group. No publishing or added permissions. Existing callbacks remain supported. See events.md.' }, + 'agent.tools': { title: { 'zh-CN': 'Agent 指令与工具', 'en-US': 'Agent instructions and tools' }, scope: 'application', description: 'API 3 contributes.agent declares instructions, tools and optional enabledWhen boolean setting. Requires permissions.agent. Register tools with context.agent.registerTool during activate; instructions reference {{full-tool-id}}. Callbacks receive invocation-scoped task/models APIs and AbortSignal, not raw Pi or credentials. See agent-settings.md.' }, + 'agent.models': { title: { 'zh-CN': '受控模型调用', 'en-US': 'Scoped model requests' }, scope: 'application', description: 'permissions.models allows invocation.models.generateText({system?,prompt,model?,maxTokens?}). Missing/null model inherits the originating turn model. Text only, no tools; usage belongs to the originating run. Up to 4 model calls and 120 seconds per invocation. Credentials remain in the host. See agent-settings.md.' }, + 'agent.task': { title: { 'zh-CN': '当前任务标题', 'en-US': 'Invoking task title' }, scope: 'application', description: 'permissions.tasks:read permits invocation.task.get() title metadata; title additionally permits rename({title,expectedRevision}). Only the invoking task; no arbitrary IDs, history or SQL. Manual titles are protected, stale writes return applied:false, renaming never changes activity time. See agent-settings.md.' }, + 'settings': { title: { 'zh-CN': '声明式设置', 'en-US': 'Declarative settings' }, scope: 'application', description: `API 3 contributes.settings declares modules/groups/items. Groups target owned modules or ${builtinSettingsModuleIds.join(', ')}. Items target owned groups or ${Object.keys(publicSettingsGroups).join(', ')}. Native boolean/string/number/select/model controls save host-owned configuration, read with context.configuration.get(). See agent-settings.md.` }, 'commands': { title: { 'zh-CN': '注册输入命令', 'en-US': 'Registered commands' }, scope: 'application', description: 'API 3 commands may declare slash:{name,description?}. Host exposes /plugin-name:name only (plugin-name is the stable suffix after the dot in the plugin ID); top-level names are system-owned. Local names match [a-z][a-z0-9-]* (max 64). Titles and descriptions may be localized. Duplicate local names or invalid/missing handler registrations fail validation or activation. Plugins may share command names. Choose a suggestion to bind its stable command ID; ambiguous plain text requires a choice. Slash executes exact names with string arguments and origin pane; no model request. See commands.md.' }, 'workbench.panes': { title: { 'zh-CN': '分屏布局订阅', 'en-US': 'Pane snapshots' }, scope: 'application', description: 'API 3 host workbench.panes and onPanesChange expose opaque id, active, visible, and rect relative to the workbench mount. Subscribe to layout and visibility changes; no task identity, history, or DOM access. See interactions.md.' }, 'workbench.interactions': { title: { 'zh-CN': '临时交互', 'en-US': 'Transient interactions' }, scope: 'application', description: 'API 3 interactions.start(title) returns id, AbortSignal, end(). Mount placements on workbench/workbench.pane declare interaction:regions or exclusive and require {interactionId,instanceId?}. Host exit and Escape revoke views without waiting for plugin cleanup. Regions mode only receives declared hit regions via view.interaction; other pixels pass through. Sessions never restore after restart. See interactions.md.' }, diff --git a/apps/buddy/shared/workbench/workbenchEvents.ts b/apps/buddy/shared/workbench/workbenchEvents.ts new file mode 100644 index 00000000..649c4a96 --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchEvents.ts @@ -0,0 +1,20 @@ +import type { EventSnapshot } from '../events/eventTypes' +import type { WorkbenchContextSnapshot } from './workbenchContext' +import type { WorkbenchPaneSnapshot } from './workbenchInteraction' +import type { ComposerActivity, ControlSnapshot } from './workbenchUi' + +export interface WorkbenchPaneEvents { + 'workbench:panes:changed': { readonly panes: EventSnapshot } +} +export interface WorkbenchContextEvents { + 'workbench:context:changed': { readonly context: WorkbenchContextSnapshot } +} +export interface ControlEvents { + 'control:changed': { readonly control: EventSnapshot } +} +export interface InteractionEvents { + 'interaction:activated': { readonly regionId: string, readonly x: number, readonly y: number } +} +export interface ComposerEvents { + 'composer:input:received': { readonly caret?: EventSnapshot } +} diff --git a/apps/buddy/shared/workbench/workbenchState.ts b/apps/buddy/shared/workbench/workbenchState.ts index d60dabc0..4a0b0bb0 100644 --- a/apps/buddy/shared/workbench/workbenchState.ts +++ b/apps/buddy/shared/workbench/workbenchState.ts @@ -1,5 +1,9 @@ import { z } from 'zod' +export type ReadonlyJsonValue = string | number | boolean | null | ReadonlyJsonArray | ReadonlyJsonObject +export interface ReadonlyJsonArray extends ReadonlyArray {} +export interface ReadonlyJsonObject { readonly [key: string]: ReadonlyJsonValue } + export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } export const workbenchStateSchema = z.object({ @@ -18,7 +22,10 @@ export const workbenchStateSchema = z.object({ export type WorkbenchState = z.infer +export const workbenchWriteOptionsSchema = z.object({ resetRecovery: z.boolean().default(false) }).strict() +export type WorkbenchWriteOptions = z.input + export interface WorkbenchStateApi { read: () => Promise - write: (state: WorkbenchState) => Promise + write: (state: WorkbenchState, options?: WorkbenchWriteOptions) => Promise } diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 18bfecf1..02d9f7eb 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -9,12 +9,13 @@ import { resolveBuddyLocale, translateBuddy } from '@/i18n/buddyI18n' import { useProvideAutomationContext } from '@/modules/automations' import { useExtensionState, useExtensionUiContributions, useExtensionViews, useProvideExtensionContext } from '@/modules/extensions' import { DesktopExtensionControl, DesktopExtensionFrameHost, DesktopExtensionMenu, DesktopExtensionOverlays, DesktopExtensionReviewHost, DesktopExtensionSlot } from '@/modules/extensions/ui' -import { useProvideSettingsContext } from '@/modules/settings' +import { usePluginSettings, useProvideSettingsContext, useSettingsRegistry } from '@/modules/settings' import { useProvideSkillsContext } from '@/modules/skills' import { useProvideTaskEnvironment, useTaskIndex, useTaskResourcePanel } from '@/modules/tasks' import DesktopBrowserGuestHost from '@/platform/browser/DesktopBrowserGuestHost.vue' import { useBrowserGuestHost } from '@/platform/browser/useBrowserGuestHost' import { requireDesktopApi } from '@/platform/desktop/desktopApi' +import { createRendererDiagnostics } from '@/platform/runtime/RendererDiagnostics' import { runtimeAvailabilityKey } from '@/platform/runtime/runtimeAvailability' import { useProvideWorkbenchCommands } from '@/shared/ui/contributions/workbenchCommands' import { useProvideWorkbenchUi } from '@/shared/ui/contributions/workbenchUiContext' @@ -22,6 +23,7 @@ import { useProvideDesktopUi } from '@/shared/ui/desktopUiContext' import { SemanticAnchorRegistry } from '@/workbench/browser/surfaces/SemanticAnchorRegistry' import { WorkbenchPaneRegistry } from '@/workbench/browser/surfaces/WorkbenchPaneRegistry' import WorkbenchSurfaceHost from '@/workbench/browser/surfaces/WorkbenchSurfaceHost.vue' +import { commandLabel } from '@/workbench/common/workbench' import { useDesktopPages } from '../router/useDesktopPages' import { useDesktopShellState } from '../shell/useDesktopShellState' import { desktopWorkbenchKey } from '../workbench/desktopWorkbenchContext' @@ -53,17 +55,22 @@ const taskIndex = useTaskIndex({ beforeTaskDelete, onSpaceCreated, }) -const workbench = useDesktopWorkbench({ api, stores, taskIndex, router, resources: getResources, onError: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.failed')) }) +const diagnostics = createRendererDiagnostics(api.app.logs) +const workbench = useDesktopWorkbench({ api, events: diagnostics.events, stores, taskIndex, router, resources: getResources, onError: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.failed')) }) +onScopeDispose(() => { + void workbench.dispose().finally(() => diagnostics.dispose()).catch(() => {}) +}) provide(desktopWorkbenchKey, workbench) const extensions = useExtensionState(api.extensions) +const settingsRegistry = useSettingsRegistry(extensions.installed) const pages = useDesktopPages(router, extensions.installed, stores.applicationSettings.language) const paneRegistry = new WorkbenchPaneRegistry(() => workbench.controller.layout.activePane) onMounted(() => paneRegistry.start()) onScopeDispose(() => paneRegistry.dispose()) -onScopeDispose(workbench.controller.subscribe(paneRegistry.invalidate)) -onScopeDispose(paneRegistry.subscribe(() => { - void api.extensions.updatePanes(paneRegistry.snapshot).catch(() => {}) -})) +onScopeDispose(workbench.controller.onDidChangeLayout(paneRegistry.invalidate).dispose) +onScopeDispose(paneRegistry.onDidChange((change) => { + void api.extensions.updatePanes([...change.snapshot]).catch(() => {}) +}).dispose) const commandRevision = ref(0) onScopeDispose(workbench.controller.registry.subscribe(() => commandRevision.value++)) onScopeDispose(workbench.controller.subscribe(() => commandRevision.value++)) @@ -71,30 +78,25 @@ useProvideWorkbenchCommands({ reportFailure: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.inputFailed')), entries: computed(() => { void commandRevision.value - return [...workbench.controller.registry.commands.values()].flatMap(command => command.slash && (!command.enabled || command.enabled(workbench.controller.context)) ? [{ id: command.id, name: command.slash.name, title: command.label, description: command.slash.description, origin: command.slash.origin }] : []) + return [...workbench.controller.registry.commands.values()].flatMap(command => command.slash && (!command.enabled || command.enabled(workbench.controller.context)) ? [{ id: command.id, name: command.slash.name, title: commandLabel(command), description: command.slash.description, origin: command.slash.origin }] : []) }), execute: async (id, argumentsText, instanceId) => { - const controller = workbench.controller - const command = controller.registry.commands.get(id) - const pane = instanceId ? controller.pane(instanceId) : controller.context.pane - const context = { ...controller.context, pane, source: 'slash' as const, arguments: argumentsText } - if (!command?.slash || (instanceId && !pane) || (command.enabled && !command.enabled(context))) + const execution = await workbench.controller.commands.execute(id, { source: 'slash', arguments: argumentsText, paneId: instanceId }) + if (execution.status === 'unavailable') throw new Error('EXTENSION_COMMAND_UNAVAILABLE') - return (await command.execute(context) ?? null) as import('@buddy-shared/workbench/workbenchState').JsonValue + return (execution.result ?? null) as import('@buddy-shared/workbench/workbenchState').JsonValue }, }) const extensionViews = useExtensionViews(api.extensions, extensions.installed, computed(() => pages.context.value.values)) -watch(() => pages.context.value.values, (values, _, cleanup) => { - const leases = Object.entries(values).map(([key, value]) => workbench.controller.contextKeys.set(key, value)) - cleanup(() => leases.forEach(dispose => dispose())) - workbench.controller.changed() -}, { immediate: true, flush: 'sync' }) +const pageContext = workbench.controller.contextKeys.bind() +onScopeDispose(pageContext.dispose) +watch(() => pages.context.value.values, values => pageContext.update(values), { immediate: true, flush: 'sync' }) const anchors = new SemanticAnchorRegistry() onScopeDispose(() => anchors.dispose()) useProvideWorkbenchUi({ anchors, panes: paneRegistry, controlRenderer: DesktopExtensionControl, slotRenderer: DesktopExtensionSlot, menuRenderer: DesktopExtensionMenu }) const ui = useExtensionUiContributions(extensions.installed, workbench.controller.configuration) useExtensionContributions({ controller: workbench.controller, renderers: workbench.renderers, persistence: workbench.persistence, installed: extensions.installed, api: api.extensions, views: extensionViews, ui, ready: () => workbench.initialized }) -useProvideExtensionContext({ authoring: { author: computed(() => stores.applicationSettings.config.value?.desktop.pluginAuthor ?? ''), save: author => stores.applicationSettings.updateSettings({ desktop: { pluginAuthor: author } }) }, state: extensions, views: extensionViews, anchors, ui, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), endInteraction: id => workbench.controller.interactions.end(id), focusView: (id) => { +useProvideExtensionContext({ settingsLocation: settingsRegistry.extensionLocation, authoring: { author: computed(() => stores.applicationSettings.config.value?.desktop.pluginAuthor ?? ''), save: author => stores.applicationSettings.updateSettings({ desktop: { pluginAuthor: author } }) }, state: extensions, views: extensionViews, anchors, ui, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), endInteraction: id => workbench.controller.interactions.end(id), focusView: (id) => { workbench.controller.focus(id) } }) onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) @@ -112,7 +114,7 @@ const resources = useTaskResourcePanel({ taskVisible: computed(() => pages.current.value === 'lexora.tasks' || !!stores.applicationSettings.config.value?.desktop.contextPanelGlobal), control: api.contextPanel, browser: api.browser, - closeView: id => workbench.controller.close(id), + closeView: async id => (await workbench.controller.close(id)).status === 'closed', closeFiles: workbench.closeContextFiles, changeSets: computed(() => selectedTask.value?.workspace.transcript.changeSets.value ?? []), runOutputs: computed(() => selectedTask.value?.workspace.transcript.runOutputs.value ?? []), @@ -243,6 +245,8 @@ useProvideTaskEnvironment({ notificationTarget, }) useProvideSettingsContext({ + registry: settingsRegistry, + pluginSettings: usePluginSettings(extensions.installed, extensions.api), shortcuts, browser: api.browser, applicationSettings: stores.applicationSettings, diff --git a/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts b/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts index 4481f816..6a09fbb4 100644 --- a/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts +++ b/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts @@ -14,7 +14,7 @@ describe('useDesktopAppState', () => { automations: emptyAutomationApi(), chat: emptyChatApi(), notifications: emptyNotificationApi(), - providers: { list: async () => [], listModels: async () => [], getDefaultModel: async () => null, onAuthChallenge: () => () => {} }, + providers: { list: async () => [], listModels: async () => [], getDefaultModel: async () => null, onChanged: () => () => {}, onAuthChallenge: () => () => {} }, runtime: { getStatus: () => status.promise, onStateChanged: () => () => {} }, }, settings: { get: async () => { throw new Error('unavailable') } }, @@ -38,7 +38,9 @@ describe('useDesktopAppState', () => { localChat: { automations: emptyAutomationApi(), chat: emptyChatApi(), + notifications: emptyNotificationApi(), providers: { + onChanged: () => () => {}, onAuthChallenge: () => () => {}, }, runtime: { @@ -90,6 +92,7 @@ describe('useDesktopAppState', () => { providers: { list: async () => [], listModels: async () => [], + onChanged: () => () => {}, onAuthChallenge: () => () => {}, }, runtime: { @@ -132,6 +135,7 @@ describe('useDesktopAppState', () => { function emptyNotificationApi() { return { + onChanged: () => () => {}, list: async () => ({ items: [], unseenCount: 0 }), } } diff --git a/apps/buddy/src/app/bootstrap/__tests__/useDesktopLifecycle.spec.ts b/apps/buddy/src/app/bootstrap/__tests__/useDesktopLifecycle.spec.ts index 160b0d72..d47e4916 100644 --- a/apps/buddy/src/app/bootstrap/__tests__/useDesktopLifecycle.spec.ts +++ b/apps/buddy/src/app/bootstrap/__tests__/useDesktopLifecycle.spec.ts @@ -1,9 +1,8 @@ -import type { ApplicationDiagnostic } from '@buddy-shared/diagnostics/applicationDiagnostic' -import { ApplicationEvents } from '@buddy-shared/observability/ApplicationEvents' +import type { RendererLifecycleReport } from '@buddy-shared/lifecycle/serviceLifecycle' import { deferred } from '@buddy-tests/deferred' import { afterEach, describe, expect, it, vi } from 'vitest' import { effectScope, shallowRef } from 'vue' -import { DesktopStartup } from '../../../../electron/main/app/DesktopStartup' +import { createStartupFixture } from '../../../../electron/main/app/__tests__/startupFixture' import { useDesktopLifecycle } from '../useDesktopLifecycle' const cleanups: (() => void)[] = [] @@ -58,17 +57,14 @@ describe('workspace hydration', () => { }) function createFixture(options: { restore?: () => Promise, failFirst?: boolean } = {}) { - const publisher = new ApplicationEvents() - const startup = new DesktopStartup(publisher) + const { startup, connect, complete } = createStartupFixture() const reads = shallowRef(0) let beforeQuit = async () => false - const send = (event: ApplicationDiagnostic) => startup.observe(event, { sourceId: event.generation ?? 'desktop' }) - send({ component: 'desktop', event: 'component.starting', level: 'info', operationId: 'desktop' }) - send({ component: 'desktop', event: 'component.ready', level: 'info', operationId: 'desktop' }) + complete('desktop') const scope = effectScope() const lifecycle = scope.run(() => useDesktopLifecycle({ api: { app: { - startup: { getState: async () => startup.state, onStateChanged: startup.onStateChange.bind(startup), reportEvent: async (event: ApplicationDiagnostic) => send(event) }, + startup: { getState: async () => startup.state, onStateChanged: startup.onStateChange.bind(startup), reportLifecycle: async (report: RendererLifecycleReport) => { startup.acceptRenderer(report) } }, onBeforeQuit: (listener: () => Promise) => { beforeQuit = listener return () => {} @@ -94,9 +90,5 @@ function createFixture(options: { restore?: () => Promise, failFirst?: boo }, } as unknown as Parameters[0]))! cleanups.push(() => scope.stop()) - const connect = (generation: string) => { - send({ component: 'runtime.connection', event: 'component.starting', level: 'info', operationId: generation, generation }) - send({ component: 'runtime.connection', event: 'component.ready', level: 'info', operationId: generation, generation }) - } return { lifecycle, reads, connect, beforeQuit: () => beforeQuit() } } diff --git a/apps/buddy/src/app/bootstrap/useDesktopAppState.ts b/apps/buddy/src/app/bootstrap/useDesktopAppState.ts index 28ae4e0c..49fad0da 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopAppState.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopAppState.ts @@ -16,7 +16,6 @@ export function useDesktopAppState(options: UseDesktopAppStateOptions) { const modelProviders = useModelProvidersStore({ api: options.api.localChat.providers, language: applicationSettings.language, - onCatalogChanged: () => void notifications.load(), }) const runtimeSupervisor = useRuntimeSupervisorStore({ api: options.api.localChat.runtime, diff --git a/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts b/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts index e5c7160e..0270fff1 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts @@ -4,7 +4,6 @@ import type { DesktopAppState } from './useDesktopAppState' import type { AutomationCapability } from '@/modules/automations' import type { TaskIndexController } from '@/modules/tasks' import { ServiceHost } from '@buddy-shared/lifecycle/ServiceHost' -import { ApplicationEvents } from '@buddy-shared/observability/ApplicationEvents' import { computed, nextTick, onScopeDispose, shallowRef, watch } from 'vue' import { useApplicationLifecycle } from '@/platform/runtime/useApplicationLifecycle' import { requireInitialState } from './requireInitialState' @@ -56,14 +55,13 @@ export function useDesktopLifecycle(options: DesktopLifecycleOptions) { tail = tail.then(async () => { if (disposed || generation !== state.value.generation || !runtimeReady.value) return - const events = new ApplicationEvents({ generation }) + const host = new ServiceHost() const reports: Promise[] = [] - const stop = events.subscribe((event) => { - const report = api.app.startup.reportEvent(event) + const subscription = host.lifecycle.onDidChange((change) => { + const report = Promise.resolve().then(() => api.app.startup.reportLifecycle({ generation, change })) void report.catch(() => {}) reports.push(report) }) - const host = new ServiceHost(events) const assertActive = () => { if (disposed || generation !== state.value.generation || !runtimeReady.value) throw new DOMException('Startup superseded', 'AbortError') @@ -111,7 +109,7 @@ export function useDesktopLifecycle(options: DesktopLifecycleOptions) { failed.value = true } finally { - stop() + subscription.dispose() initialAttemptSettled = true resolveReady() } diff --git a/apps/buddy/src/app/shell/window/DesktopTitleBar.vue b/apps/buddy/src/app/shell/window/DesktopTitleBar.vue index b4430de6..1b94e57a 100644 --- a/apps/buddy/src/app/shell/window/DesktopTitleBar.vue +++ b/apps/buddy/src/app/shell/window/DesktopTitleBar.vue @@ -77,8 +77,8 @@ onScopeDispose(controller.registry.register('lexora.desktopCommands', (scope) => for (const command of DESKTOP_COMMAND_REGISTRY) { scope.command({ id: command.id, - get label() { return t(`desktop.command.${command.id}`) }, - get keybinding() { return platform.value === 'darwin' ? command.macosKeybinding ?? command.keybinding : command.keybinding }, + label: () => t(`desktop.command.${command.id}`), + keybinding: () => platform.value === 'darwin' ? command.macosKeybinding ?? command.keybinding : command.keybinding, alternateKeybindings: command.alternateKeybindings, shortcutScope: 'application', execute: () => executeDesktopCommand(command.id), diff --git a/apps/buddy/src/app/workbench/ActiveTaskProjection.ts b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts new file mode 100644 index 00000000..f0d53253 --- /dev/null +++ b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts @@ -0,0 +1,31 @@ +import type { TaskWorkspacePool } from './TaskWorkspacePool' +import type { TaskCapability } from '@/modules/tasks/contracts' +import type { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { computed, shallowRef } from 'vue' + +export class ActiveTaskProjection { + readonly #active = shallowRef(null) + readonly current = computed(() => this.#active.value) + readonly #subscriptions: (() => void)[] + + constructor(readonly controller: WorkbenchController, readonly pool: TaskWorkspacePool) { + this.#subscriptions = [ + controller.onDidChangeFocus(() => this.reconcile()).dispose, + controller.onDidChangeLayout((change) => { + if (change.kind !== 'resized' && change.kind !== 'auxiliary') + this.reconcile() + }).dispose, + pool.onDidChange(() => this.reconcile()).dispose, + ] + this.reconcile() + } + + reconcile(): void { + const pane = this.controller.pane(this.controller.layout.activePane) + const view = pane?.view ? this.controller.layout.views[pane.view] : null + const task = view ? this.pool.peek(view.resource) : undefined + this.#active.value = task?.workspace.restoration.state.value === 'ready' ? task : null + } + + dispose(): void { for (const stop of this.#subscriptions) stop() } +} diff --git a/apps/buddy/src/app/workbench/ContextTabProjection.ts b/apps/buddy/src/app/workbench/ContextTabProjection.ts new file mode 100644 index 00000000..7a69d4d7 --- /dev/null +++ b/apps/buddy/src/app/workbench/ContextTabProjection.ts @@ -0,0 +1,45 @@ +import type { TaskResourcePanel } from '@/modules/tasks/contracts' +import type { WorkbenchController } from '@/workbench/services/WorkbenchController' + +export class ContextTabProjection { + readonly #subscriptions: (() => void)[] + + constructor(readonly controller: WorkbenchController, readonly resources: () => TaskResourcePanel, readonly ready: () => boolean) { + this.#subscriptions = [ + controller.onDidChangeLayout((change) => { + if (change.changedViewIds.length || change.removedViewIds.length || change.kind === 'restored') + this.reconcile() + }).dispose, + controller.onDidChangeFocus(() => this.#select()).dispose, + ] + } + + reconcile(): void { + if (!this.ready()) + return + const resources = this.resources() + for (const tab of resources.allTabs.value) { + if (tab.kind === 'view' && !this.controller.layout.views[tab.viewId]) + resources.removeView(tab.id) + } + for (const view of Object.values(this.controller.layout.views)) { + if (view.location !== 'context' || typeof view.state.contextTabId === 'string') + continue + if (!resources.hasTab(view.id)) + resources.openView(view.id, view.title) + else if (resources.allTabs.value.some(tab => tab.id === view.id && tab.kind === 'view' && tab.label !== view.title)) + resources.updateView(view.id, view.title) + } + this.#select() + } + + dispose(): void { for (const stop of this.#subscriptions) stop() } + + #select(): void { + if (!this.ready()) + return + const view = this.controller.context.view + if (view?.location === 'context') + this.resources().selectTab(typeof view.state.contextTabId === 'string' ? view.state.contextTabId : view.id) + } +} diff --git a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue index 3c826d12..f29f5fbe 100644 --- a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue +++ b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue @@ -41,8 +41,6 @@ async function load() { if (disposed) return task.value = loaded - if (props.visible && workbench.controller.owner(props.view.id)?.id === workbench.controller.layout.activePane) - workbench.activeTask.value = loaded } catch { if (!disposed) { @@ -56,10 +54,6 @@ watch(() => task.value?.session.currentTitle.value, (title) => { if (title) workbench.controller.updateView(props.view.id, { title }) }) -watch(() => props.visible, (visible) => { - if (visible && task.value && workbench.controller.context.view?.id === props.view.id) - workbench.activeTask.value = task.value -})