fix: refresh magic notes after external updates
Subscribe the open Magic Notes workspace to committed database changes from desktop and Agent/MCP writes. Preserve selections and unsaved drafts during refresh and external deletion. File-level commit includes existing runtime edits in shared files; their remaining dependencies are still in the working tree. Release note: Magic Notes now updates automatically after Agent/MCP edits while preserving your selection and unsaved drafts.
This commit is contained in:
@@ -783,8 +783,10 @@ describe('KnowledgeMcpGateway', () => {
|
||||
const { service } = createService()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const onMagicNotesChanged = vi.fn()
|
||||
const database = new AssistantDatabase(
|
||||
join(directory, 'assistant.sqlite')
|
||||
join(directory, 'assistant.sqlite'),
|
||||
{ onMagicNotesChanged }
|
||||
)
|
||||
databases.push(database)
|
||||
database.initialize('C:\\Workspace')
|
||||
@@ -838,12 +840,20 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
])
|
||||
expect(created.entries[0]?.content).toBe('核对构建产物')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(1)
|
||||
gateway.updateMagicNote(writeToken, {
|
||||
noteId: created.id,
|
||||
title: 'Updated plan',
|
||||
expectedRevision: created.revision
|
||||
})
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(2)
|
||||
const withEntry = gateway.createMagicNoteEntry(writeToken, {
|
||||
noteId: created.id,
|
||||
content: '通知发布负责人'
|
||||
})
|
||||
const entry = withEntry.entries[1]!
|
||||
expect(entry.content).toBe('通知发布负责人')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(3)
|
||||
|
||||
const updatedEntry = gateway.updateMagicNoteEntry(writeToken, {
|
||||
entryId: entry.id,
|
||||
@@ -859,6 +869,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expectedRevision: entry.revision
|
||||
})
|
||||
).toThrow('已被更新')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
|
||||
|
||||
const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, {
|
||||
entryId: entry.id,
|
||||
@@ -873,6 +884,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expectedRevision: withoutEntry.revision
|
||||
})
|
||||
).toEqual({ deleted: true, noteId: created.id })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(6)
|
||||
expect(() =>
|
||||
gateway.getMagicNote(readToken, { noteId: created.id })
|
||||
).toThrow('笔记不存在')
|
||||
|
||||
@@ -31,6 +31,7 @@ afterEach(async () => {
|
||||
|
||||
async function createDatabase(
|
||||
options: {
|
||||
onMagicNotesChanged?: () => void
|
||||
onMagicTodosChanged?: () => void
|
||||
} = {}
|
||||
): Promise<AssistantDatabase> {
|
||||
@@ -3438,7 +3439,7 @@ describe('AssistantDatabase', () => {
|
||||
raw.close()
|
||||
})
|
||||
|
||||
it('creates and recovers a remote-authoritative conversation task', async () => {
|
||||
it.each(['failed', 'cancelled'] as const)('creates and recovers a remote-authoritative conversation task, then ends it as %s', async (terminalStatus) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-remote-conversation-recovery-')
|
||||
)
|
||||
@@ -3731,14 +3732,15 @@ describe('AssistantDatabase', () => {
|
||||
content: recoveredText,
|
||||
state: 'complete'
|
||||
})
|
||||
reopened.failRecoverableRemoteTask(
|
||||
reopened.endRecoverableRemoteTask(
|
||||
taskId,
|
||||
'远端请求已不存在'
|
||||
'远端请求已不存在',
|
||||
terminalStatus
|
||||
)
|
||||
expect(
|
||||
reopened.listTasks().find((task) => task.id === taskId)
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
error: '远端请求已不存在'
|
||||
})
|
||||
expect(
|
||||
@@ -6546,6 +6548,65 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('publishes committed note, entry, todo and analysis writes but not failed writes', async () => {
|
||||
const onMagicNotesChanged = vi.fn()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-events-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath, { onMagicNotesChanged })
|
||||
database.initialize('C:\\Workspace')
|
||||
const reader = new DatabaseSync(databasePath, { readOnly: true })
|
||||
// A separate connection must already see each emitted write.
|
||||
const observed: string[][] = []
|
||||
onMagicNotesChanged.mockImplementation(() => {
|
||||
observed.push((reader.prepare('SELECT title FROM magic_notes').all() as Array<{ title: string }>).map((note) => note.title))
|
||||
})
|
||||
try {
|
||||
const note = database.createMagicNote({ title: 'Empty note' })
|
||||
expect(observed).toEqual([['Empty note']])
|
||||
database.updateMagicNote({ noteId: note.id, title: 'Renamed', pinned: true, expectedRevision: 0 })
|
||||
expect(observed.at(-1)).toEqual(['Renamed'])
|
||||
expect(() => database.updateMagicNote({ noteId: note.id, title: 'Stale', expectedRevision: 0 })).toThrow()
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(2)
|
||||
const content = {
|
||||
version: 1 as const,
|
||||
ops: [{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' as const } }]
|
||||
}
|
||||
const withEntry = database.createMagicNoteEntry({ noteId: note.id, content, plainText: 'Task' })
|
||||
const entry = withEntry.entries[0]!
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(3)
|
||||
database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Task', expectedRevision: entry.revision })
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Stale', expectedRevision: entry.revision })).toThrow()
|
||||
expect(() => database.createMagicNoteEntry({ noteId: randomUUID(), content, plainText: 'Missing' })).toThrow()
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
|
||||
const todo = database.listMagicTodos()[0]!
|
||||
database.updateMagicTodo({ todoId: todo.id, completed: false, expectedRevision: todo.revision })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
|
||||
const completed = database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(5)
|
||||
database.saveMagicTodoAnalysis({ todoId: todo.id, comments: [], expectedRevision: completed.revision })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(6)
|
||||
database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: database.getMagicNoteEntry(entry.id).revision })
|
||||
expect(() => database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: 0 })).toThrow()
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(7)
|
||||
database.deleteMagicNoteEntry(entry.id)
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(8)
|
||||
database.deleteMagicNote(note.id)
|
||||
expect(() => database.deleteMagicNote(note.id)).toThrow()
|
||||
expect(() => database.deleteMagicNoteEntry(entry.id)).toThrow()
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(9)
|
||||
expect(observed.at(-1)).toEqual([])
|
||||
database.createMagicNote({ title: 'With content', content })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(10)
|
||||
database.clearAssistantData()
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(11)
|
||||
expect(observed.at(-1)).toEqual([])
|
||||
} finally {
|
||||
reader.close()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts incomplete todos and publishes todo-changing writes', async () => {
|
||||
const onMagicTodosChanged = vi.fn()
|
||||
const database = await createDatabase({ onMagicTodosChanged })
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
assistantIdSchema,
|
||||
conversationAnsweredQuestionSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
AssistantTask,
|
||||
ActivityHistorySnapshot,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
ConversationBranchInput,
|
||||
ConversationMessageBlock,
|
||||
ConversationSubagentActivity,
|
||||
@@ -1761,6 +1763,7 @@ export class AssistantDatabase {
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
private readonly options: {
|
||||
onMagicNotesChanged?: () => void
|
||||
onMagicTodosChanged?: () => void
|
||||
} = {}
|
||||
) {}
|
||||
@@ -2063,6 +2066,7 @@ export class AssistantDatabase {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
this.options.onMagicNotesChanged?.()
|
||||
}
|
||||
|
||||
listProjects(includeArchived = false): AssistantProject[] {
|
||||
@@ -3675,6 +3679,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
const detail = this.getMagicNote(id)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
if (input.content) {
|
||||
this.notifyMagicTodosChanged()
|
||||
}
|
||||
@@ -3707,7 +3712,9 @@ export class AssistantDatabase {
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('笔记已被更新,请刷新后重试')
|
||||
}
|
||||
return this.getMagicNote(input.noteId)
|
||||
const detail = this.getMagicNote(input.noteId)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
return detail
|
||||
}
|
||||
|
||||
deleteMagicNote(noteId: string): void {
|
||||
@@ -3717,6 +3724,7 @@ export class AssistantDatabase {
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('笔记不存在')
|
||||
}
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.notifyMagicTodosChanged()
|
||||
}
|
||||
|
||||
@@ -3771,6 +3779,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
const detail = this.getMagicNote(input.noteId)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.notifyMagicTodosChanged()
|
||||
return detail
|
||||
}
|
||||
@@ -3835,6 +3844,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
const detail = this.getMagicNote(existing.note_id)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.notifyMagicTodosChanged()
|
||||
return detail
|
||||
}
|
||||
@@ -3866,6 +3876,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
const detail = this.getMagicNote(existing.note_id)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.notifyMagicTodosChanged()
|
||||
return detail
|
||||
}
|
||||
@@ -3923,7 +3934,9 @@ export class AssistantDatabase {
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('记录已被更新,请重新分析')
|
||||
}
|
||||
return this.getMagicNote(existing.note_id)
|
||||
const detail = this.getMagicNote(existing.note_id)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
return detail
|
||||
}
|
||||
|
||||
listMagicTodos(): MagicTodoItem[] {
|
||||
@@ -4077,6 +4090,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
const todo = this.getMagicTodo(input.todoId)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.notifyMagicTodosChanged()
|
||||
return todo
|
||||
}
|
||||
@@ -4118,7 +4132,9 @@ export class AssistantDatabase {
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('待办已被更新,请刷新后重试')
|
||||
}
|
||||
return this.getMagicTodo(input.todoId)
|
||||
const todo = this.getMagicTodo(input.todoId)
|
||||
this.options.onMagicNotesChanged?.()
|
||||
return todo
|
||||
}
|
||||
|
||||
listPendingDelegationResults(): Array<{
|
||||
@@ -4581,11 +4597,55 @@ export class AssistantDatabase {
|
||||
})
|
||||
}
|
||||
|
||||
failRecoverableRemoteTask(taskIdInput: string, messageInput: string): void {
|
||||
recordRemoteTaskQuestionAnswer(
|
||||
taskIdInput: string,
|
||||
answerInput: ConversationAnsweredQuestion
|
||||
): boolean {
|
||||
const taskId = assistantIdSchema.parse(taskIdInput)
|
||||
const answer = conversationAnsweredQuestionSchema.parse(answerInput)
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const row = database.prepare(
|
||||
`SELECT m.id, m.conversation_id, m.metadata_json
|
||||
FROM tasks t JOIN messages m
|
||||
ON m.id = t.current_assistant_message_id
|
||||
AND m.conversation_id = t.conversation_id AND m.request_id = t.id
|
||||
WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'`
|
||||
).get(taskId) as {
|
||||
id: string; conversation_id: string; metadata_json: string
|
||||
} | undefined
|
||||
if (!row) {
|
||||
database.exec('COMMIT')
|
||||
return false
|
||||
}
|
||||
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
||||
metadata.answeredQuestions = [
|
||||
...(metadata.answeredQuestions ?? []).filter(item => item.questionId !== answer.questionId),
|
||||
answer
|
||||
]
|
||||
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(metadata), row.id)
|
||||
database.prepare(
|
||||
'UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?'
|
||||
).run(new Date().toISOString(), row.conversation_id)
|
||||
database.exec('COMMIT')
|
||||
return true
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
endRecoverableRemoteTask(
|
||||
taskIdInput: string,
|
||||
messageInput: string,
|
||||
status: 'failed' | 'cancelled'
|
||||
): void {
|
||||
const taskId = assistantIdSchema.parse(taskIdInput)
|
||||
const message = messageInput.trim().slice(0, 4_000)
|
||||
if (!message) {
|
||||
throw new Error('远程恢复失败原因不能为空')
|
||||
throw new Error('远程任务终止原因不能为空')
|
||||
}
|
||||
const database = this.requireDatabase()
|
||||
const now = new Date().toISOString()
|
||||
@@ -4657,10 +4717,10 @@ export class AssistantDatabase {
|
||||
attachments: metadata.attachments,
|
||||
answeredQuestions: metadata.answeredQuestions
|
||||
})
|
||||
const failed = reduceRecoveredAgentEvent(current, {
|
||||
const ended = reduceRecoveredAgentEvent(current, {
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status,
|
||||
message
|
||||
})
|
||||
database
|
||||
@@ -4670,9 +4730,9 @@ export class AssistantDatabase {
|
||||
WHERE id = ? AND conversation_id = ? AND request_id = ?`
|
||||
)
|
||||
.run(
|
||||
failed.content,
|
||||
failed.state,
|
||||
serializeConversationMessageMetadata(failed),
|
||||
ended.content,
|
||||
ended.state,
|
||||
serializeConversationMessageMetadata(ended),
|
||||
owner.current_assistant_message_id,
|
||||
owner.conversation_id,
|
||||
taskId
|
||||
@@ -4680,10 +4740,10 @@ export class AssistantDatabase {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'failed', error = ?, completed_at = ?
|
||||
SET status = ?, error = ?, completed_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(message, now, taskId)
|
||||
.run(status, message, now, taskId)
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO task_events
|
||||
@@ -4695,7 +4755,7 @@ export class AssistantDatabase {
|
||||
JSON.stringify({
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status,
|
||||
message
|
||||
}),
|
||||
now
|
||||
@@ -7747,7 +7807,7 @@ export class AssistantDatabase {
|
||||
return readProject(this.requireDatabase(), projectId)
|
||||
}
|
||||
|
||||
private getTask(taskId: string): AssistantTask {
|
||||
getTask(taskId: string): AssistantTask {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(`${taskSelect} WHERE tasks.id = ?`)
|
||||
.get(taskId) as TaskRow | undefined
|
||||
|
||||
+18
-1
@@ -22,6 +22,7 @@ import {
|
||||
import { AgentRuntimeController } from './agent/runtime-controller'
|
||||
import type { AgentRuntime } from './agent/runtime'
|
||||
import { SelectedRuntimeManager } from './agent/selected-runtime-manager'
|
||||
import { LocalRuntimeRegistry } from './agent/local-runtime-registry'
|
||||
import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway'
|
||||
import {
|
||||
applyRuntimeSelection,
|
||||
@@ -206,6 +207,7 @@ let removeIpcHandlers: (() => Promise<void>) | undefined
|
||||
let removeFeedbackIpcHandler: (() => void) | undefined
|
||||
let runtime: AgentRuntimeController | undefined
|
||||
let selectedRuntimeManager: SelectedRuntimeManager | undefined
|
||||
const localRuntimeRegistry = new LocalRuntimeRegistry()
|
||||
let knowledgeService: KnowledgeService | undefined
|
||||
let knowledgeGateway: KnowledgeMcpGateway | undefined
|
||||
let assistantDatabase: AssistantDatabase | undefined
|
||||
@@ -881,6 +883,17 @@ if (hasSingleInstanceLock) {
|
||||
const startupAssistantDatabase = new AssistantDatabase(
|
||||
join(app.getPath('userData'), 'assistant.sqlite'),
|
||||
{
|
||||
onMagicNotesChanged: () => {
|
||||
queueMicrotask(() => {
|
||||
if (
|
||||
mainWindow &&
|
||||
!mainWindow.isDestroyed() &&
|
||||
!mainWindow.webContents.isDestroyed()
|
||||
) {
|
||||
mainWindow.webContents.send(ipcChannels.magicNotesChanged)
|
||||
}
|
||||
})
|
||||
},
|
||||
onMagicTodosChanged: () => {
|
||||
queueMicrotask(() => {
|
||||
if (
|
||||
@@ -954,6 +967,7 @@ if (hasSingleInstanceLock) {
|
||||
: Promise.resolve([])
|
||||
])
|
||||
return createAgentRuntime(defaultWorkspace, settings, {
|
||||
localRuntimeRegistry,
|
||||
skillInstructions: skillContext.instructions,
|
||||
skillPackages: skillContext.packages,
|
||||
mcpServers,
|
||||
@@ -1148,7 +1162,8 @@ if (hasSingleInstanceLock) {
|
||||
undefined,
|
||||
undefined,
|
||||
observeDesktopFailure,
|
||||
createSelectedStatusRuntime
|
||||
createSelectedStatusRuntime,
|
||||
(conversationId) => localRuntimeRegistry.releaseConversation(conversationId)
|
||||
)
|
||||
const contextManager = new ContextManager({
|
||||
parseDocument: documentParsingService.parse
|
||||
@@ -1229,6 +1244,7 @@ if (hasSingleInstanceLock) {
|
||||
nextSubagentProfileRuntimes
|
||||
)
|
||||
await selectedRuntimeManager?.reset()
|
||||
localRuntimeRegistry.reset(nextRuntime)
|
||||
const previousEmbeddingProvider = activeEmbeddingProvider
|
||||
activeEmbeddingProvider = nextEmbeddingProvider
|
||||
activeRerankProvider = nextRerankProvider
|
||||
@@ -1530,6 +1546,7 @@ app.on('before-quit', (event) => {
|
||||
() => documentOcrModelManager?.dispose(),
|
||||
() => documentOcrBroker?.dispose()
|
||||
],
|
||||
[() => localRuntimeRegistry.dispose()],
|
||||
[() => terminalSessionManager?.dispose()],
|
||||
[() => localToolEnvironmentService?.dispose()],
|
||||
[() => managedRemoteExecutionServices?.dispose()],
|
||||
|
||||
Reference in New Issue
Block a user