feat: improve desktop reliability and customization
Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely. Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility. Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
@@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
isUntouchedBuiltInDefaultProject
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
@@ -156,7 +161,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 23', async () => {
|
||||
it('migrates existing databases to schema version 25', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -185,7 +190,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(23)
|
||||
).toBe(25)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -200,7 +205,12 @@ describe('AssistantDatabase', () => {
|
||||
.all()
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'runtime_selection_json' })
|
||||
expect.objectContaining({ name: 'runtime_selection_json' }),
|
||||
expect.objectContaining({
|
||||
name: 'built_in_default',
|
||||
notnull: 1,
|
||||
dflt_value: '0'
|
||||
})
|
||||
])
|
||||
)
|
||||
const foreignKeys = current
|
||||
@@ -268,6 +278,174 @@ describe('AssistantDatabase', () => {
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('backfills one exact legacy built-in default candidate', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-default-project-migration-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
DROP INDEX projects_built_in_default_unique;
|
||||
ALTER TABLE projects DROP COLUMN built_in_default;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
const [project] = migrated.listProjects()
|
||||
expect(project).toMatchObject({
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
builtInDefault: true
|
||||
})
|
||||
expect(
|
||||
project && isUntouchedBuiltInDefaultProject(project)
|
||||
).toBe(true)
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('does not backfill an ambiguous legacy default identity', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-ambiguous-default-project-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const independent = initial.createProject({
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'D:\\Independent',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
expect(independent.builtInDefault).toBe(false)
|
||||
expect(isUntouchedBuiltInDefaultProject(independent)).toBe(false)
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
DROP INDEX projects_built_in_default_unique;
|
||||
ALTER TABLE projects DROP COLUMN built_in_default;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
expect(
|
||||
migrated
|
||||
.listProjects()
|
||||
.map((project) => project.builtInDefault)
|
||||
).toEqual([false, false])
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('does not mark a later exact clone after the original default was edited', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-edited-default-project-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const original = initial.listProjects()[0]!
|
||||
initial.updateProject(original.id, {
|
||||
name: '已编辑默认项目',
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: original.rootPath,
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
const clone = initial.createProject({
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: original.rootPath,
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
DROP INDEX projects_built_in_default_unique;
|
||||
ALTER TABLE projects DROP COLUMN built_in_default;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
expect(
|
||||
migrated
|
||||
.listProjects()
|
||||
.filter((project) => project.builtInDefault)
|
||||
).toEqual([])
|
||||
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('does not mark a later exact clone after the original default was deleted', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-deleted-default-project-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const original = initial.listProjects()[0]!
|
||||
const clone = initial.createProject({
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: original.rootPath,
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
initial.deleteProject(original.id, original.name)
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
DROP INDEX projects_built_in_default_unique;
|
||||
ALTER TABLE projects DROP COLUMN built_in_default;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('does not backfill when no exact legacy candidate exists', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-missing-default-project-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
UPDATE projects
|
||||
SET updated_at = created_at || '-edited';
|
||||
DROP INDEX projects_built_in_default_unique;
|
||||
ALTER TABLE projects DROP COLUMN built_in_default;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
expect(migrated.listProjects()[0]?.builtInDefault).toBe(false)
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('idempotently migrates version 5 databases to computer control audit schema', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-control-audit-migration-')
|
||||
@@ -298,7 +476,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(23)
|
||||
).toBe(25)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -436,7 +614,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 23 })
|
||||
).toEqual({ user_version: 25 })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -566,11 +744,34 @@ describe('AssistantDatabase', () => {
|
||||
const database = await createDatabase()
|
||||
const [defaultProject] = database.listProjects()
|
||||
expect(defaultProject).toMatchObject({
|
||||
name: '默认项目',
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'C:\\Workspace',
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
status: 'active'
|
||||
})
|
||||
expect(defaultProject?.runtimeSelection).toBeUndefined()
|
||||
expect(defaultProject?.createdAt).toBe(defaultProject?.updatedAt)
|
||||
expect(
|
||||
defaultProject &&
|
||||
isUntouchedBuiltInDefaultProject(defaultProject)
|
||||
).toBe(true)
|
||||
const reconfiguredDefault = database.updateProject(
|
||||
defaultProject!.id,
|
||||
{
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'D:\\Moved',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
}
|
||||
)
|
||||
expect(reconfiguredDefault.builtInDefault).toBe(true)
|
||||
expect(
|
||||
isUntouchedBuiltInDefaultProject(reconfiguredDefault)
|
||||
).toBe(true)
|
||||
expect(database.listExperts()).toHaveLength(3)
|
||||
|
||||
const project = database.createProject({
|
||||
@@ -579,6 +780,7 @@ describe('AssistantDatabase', () => {
|
||||
rootPath: 'C:\\Release',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
expect(project.builtInDefault).toBe(false)
|
||||
expect(database.listProjects()).toHaveLength(2)
|
||||
|
||||
const updated = database.updateProject(project.id, {
|
||||
@@ -863,6 +1065,192 @@ describe('AssistantDatabase', () => {
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('keeps exhausted channel results terminal and observable', async () => {
|
||||
const database = await createDatabase()
|
||||
const entry = database.enqueueChannelResult({
|
||||
channel: 'weixin',
|
||||
eventId: 'terminal-event',
|
||||
conversationId: 'conversation-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '已完成',
|
||||
attachments: [
|
||||
{
|
||||
name: 'result.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
kind: 'file',
|
||||
dataBase64: 'eA=='
|
||||
}
|
||||
]
|
||||
})
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
database.markChannelResult(entry.id, 'failed')
|
||||
}
|
||||
|
||||
const terminal = database.listUndeliveredChannelResults()
|
||||
expect(terminal).toEqual([
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
state: 'terminal',
|
||||
attempts: 5,
|
||||
message: expect.objectContaining({
|
||||
eventId: 'terminal-event',
|
||||
output: '已完成'
|
||||
})
|
||||
})
|
||||
])
|
||||
expect(terminal[0]?.message).not.toHaveProperty('attachments')
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates exhausted legacy outbox failures to terminal state', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const entry = initial.enqueueChannelResult({
|
||||
channel: 'weixin',
|
||||
eventId: 'legacy-terminal-event',
|
||||
conversationId: 'conversation-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '已完成',
|
||||
attachments: [
|
||||
{
|
||||
name: 'legacy.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
kind: 'file',
|
||||
dataBase64: 'eA=='
|
||||
}
|
||||
]
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy
|
||||
.prepare(
|
||||
`UPDATE channel_outbox
|
||||
SET state = 'failed', attempts = 5
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(entry.id)
|
||||
legacy.exec('PRAGMA user_version = 23')
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
const terminal = migrated.listUndeliveredChannelResults()
|
||||
expect(terminal).toEqual([
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
state: 'terminal',
|
||||
attempts: 5
|
||||
})
|
||||
])
|
||||
expect(terminal[0]?.message).not.toHaveProperty('attachments')
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('rolls back both heartbeat failure updates atomically', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-heartbeat-rollback-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const config = database.createHeartbeatConfig(
|
||||
{
|
||||
scope: { kind: 'global' },
|
||||
name: '事务心跳',
|
||||
timezone: 'UTC',
|
||||
recurrence: { type: 'daily', localTime: '09:00' },
|
||||
enabled: true,
|
||||
lookbackHours: 24,
|
||||
retentionDays: 30
|
||||
},
|
||||
new Date('2026-08-16T00:00:00.000Z')
|
||||
)
|
||||
const claim = database.claimHeartbeatNow(
|
||||
config.id,
|
||||
'heartbeat-rollback',
|
||||
'test-owner',
|
||||
new Date('2026-08-16T01:00:00.000Z')
|
||||
)
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
raw.exec(`
|
||||
CREATE TRIGGER reject_heartbeat_config_failure
|
||||
BEFORE UPDATE OF last_status ON heartbeat_configs
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced config update failure');
|
||||
END;
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
expect(() =>
|
||||
database.failHeartbeatRun(
|
||||
claim,
|
||||
'runtime failed',
|
||||
new Date('2026-08-16T01:01:00.000Z')
|
||||
)
|
||||
).toThrow('forced config update failure')
|
||||
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
|
||||
status: 'claimed',
|
||||
attemptCount: 1,
|
||||
completedAt: undefined,
|
||||
error: undefined
|
||||
})
|
||||
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
|
||||
'claimed'
|
||||
)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('rejects heartbeat failure after its lease expires', async () => {
|
||||
const database = await createDatabase()
|
||||
const config = database.createHeartbeatConfig(
|
||||
{
|
||||
scope: { kind: 'global' },
|
||||
name: '租约过期心跳',
|
||||
timezone: 'UTC',
|
||||
recurrence: { type: 'daily', localTime: '09:00' },
|
||||
enabled: true,
|
||||
lookbackHours: 24,
|
||||
retentionDays: 30
|
||||
},
|
||||
new Date('2026-08-16T00:00:00.000Z')
|
||||
)
|
||||
const claim = database.claimHeartbeatNow(
|
||||
config.id,
|
||||
'expired-heartbeat',
|
||||
'expired-owner',
|
||||
new Date('2026-08-16T01:00:00.000Z'),
|
||||
60_000
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
database.failHeartbeatRun(
|
||||
claim,
|
||||
'late worker failure',
|
||||
new Date('2026-08-16T01:01:00.001Z')
|
||||
)
|
||||
).toThrow('Heartbeat lease is no longer active')
|
||||
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
|
||||
status: 'claimed',
|
||||
completedAt: undefined,
|
||||
error: undefined
|
||||
})
|
||||
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
|
||||
'claimed'
|
||||
)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('preserves legacy channel event claims while adding account identity', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-channel-event-migration-')
|
||||
@@ -2299,6 +2687,7 @@ describe('AssistantDatabase', () => {
|
||||
it('explicitly deletes only local conversations and cascades messages', async () => {
|
||||
const database = await createDatabase()
|
||||
const localId = '00000000-0000-4000-8000-000000000521'
|
||||
const localTaskId = '00000000-0000-4000-8000-000000000523'
|
||||
database.replaceConversations([
|
||||
{
|
||||
id: localId,
|
||||
@@ -2341,6 +2730,28 @@ describe('AssistantDatabase', () => {
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2027-01-01T00:00:00.000Z'
|
||||
})
|
||||
database.createTask({
|
||||
id: localTaskId,
|
||||
conversationId: localId,
|
||||
title: '本地对话任务',
|
||||
instructions: '生成仅属于对话的回复',
|
||||
workMode: 'ask'
|
||||
})
|
||||
database.updateTaskStatus(localTaskId, 'completed')
|
||||
const hiddenReply = database.createTextArtifact({
|
||||
taskId: localTaskId,
|
||||
title: '本地对话回复',
|
||||
content: '删除对话后不得进入成果列表'
|
||||
})
|
||||
database.saveDelegationResult(localTaskId, {
|
||||
status: 'completed',
|
||||
output: '不应残留'
|
||||
})
|
||||
expect(
|
||||
database
|
||||
.listArtifacts()
|
||||
.some((artifact) => artifact.id === hiddenReply.id)
|
||||
).toBe(false)
|
||||
|
||||
expect(database.deleteLocalConversation(localId)).toBe(true)
|
||||
expect(database.deleteLocalConversation(localId)).toBe(false)
|
||||
@@ -2357,6 +2768,10 @@ describe('AssistantDatabase', () => {
|
||||
expect(() =>
|
||||
database.getConversation(localId)
|
||||
).toThrow('对话不存在')
|
||||
expect(() => database.getArtifact(hiddenReply.id)).toThrow(
|
||||
'成果不存在'
|
||||
)
|
||||
expect(database.listPendingDelegationResults()).toEqual([])
|
||||
expect(() =>
|
||||
database.deleteLocalConversation(remote.id)
|
||||
).toThrow('远程对话不能作为本地对话删除')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
normalizeInteractiveWorkMode,
|
||||
@@ -82,6 +84,7 @@ type ProjectRow = {
|
||||
runtime_selection_json: string | null
|
||||
kind: AssistantProject['kind']
|
||||
channel: ProjectChannel | null
|
||||
built_in_default: number
|
||||
status: AssistantProject['status']
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -427,6 +430,7 @@ function toProject(row: ProjectRow): AssistantProject {
|
||||
: parseRuntimeSelection(row.runtime_selection_json),
|
||||
kind: row.kind,
|
||||
channel: row.channel ?? undefined,
|
||||
builtInDefault: row.built_in_default === 1,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
@@ -982,12 +986,15 @@ export class AssistantDatabase {
|
||||
.prepare('SELECT COUNT(*) AS count FROM projects')
|
||||
.get() as { count: number }
|
||||
if (count.count === 0) {
|
||||
this.createProject({
|
||||
name: '默认项目',
|
||||
description: 'GoodBuddy 默认工作区',
|
||||
rootPath: defaultRootPath,
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
this.createLocalProject(
|
||||
{
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: defaultRootPath,
|
||||
defaultWorkMode: 'ask'
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
const expertCount = database
|
||||
.prepare('SELECT COUNT(*) AS count FROM experts')
|
||||
@@ -1283,6 +1290,13 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
createProject(input: ProjectCreateInput): AssistantProject {
|
||||
return this.createLocalProject(input, false)
|
||||
}
|
||||
|
||||
private createLocalProject(
|
||||
input: ProjectCreateInput,
|
||||
builtInDefault: boolean
|
||||
): AssistantProject {
|
||||
const database = this.requireDatabase()
|
||||
const id = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
@@ -1290,9 +1304,9 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, description, root_path, default_work_mode,
|
||||
runtime_selection_json, kind, channel, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)`
|
||||
runtime_selection_json, kind, channel, built_in_default,
|
||||
status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1303,6 +1317,7 @@ export class AssistantDatabase {
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
builtInDefault ? 1 : 0,
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -1913,6 +1928,32 @@ export class AssistantDatabase {
|
||||
)`
|
||||
)
|
||||
.run(conversationId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM delegation_outbox
|
||||
WHERE task_id IN (
|
||||
SELECT id FROM tasks WHERE conversation_id = ?
|
||||
)`
|
||||
)
|
||||
.run(conversationId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM artifacts
|
||||
WHERE kind = 'markdown'
|
||||
AND task_id IN (
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE conversation_id = ?
|
||||
AND (
|
||||
origin = 'user'
|
||||
OR (
|
||||
origin = 'delegation'
|
||||
AND conversation_id NOT LIKE 'delegation:%'
|
||||
)
|
||||
)
|
||||
)`
|
||||
)
|
||||
.run(conversationId)
|
||||
database
|
||||
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
|
||||
.run(conversationId)
|
||||
@@ -2214,7 +2255,11 @@ export class AssistantDatabase {
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`UPDATE channel_outbox
|
||||
SET state = ?,
|
||||
SET state = CASE
|
||||
WHEN ? = 'failed' AND attempts + 1 >= 5
|
||||
THEN 'terminal'
|
||||
ELSE ?
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
message_json = CASE
|
||||
WHEN ? = 'delivered' OR attempts + 1 >= 5
|
||||
@@ -2223,7 +2268,7 @@ export class AssistantDatabase {
|
||||
END
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(state, state, id)
|
||||
.run(state, state, state, id)
|
||||
}
|
||||
|
||||
listUndeliveredChannelResults(
|
||||
@@ -2232,7 +2277,7 @@ export class AssistantDatabase {
|
||||
): Array<{
|
||||
id: string
|
||||
message: ChannelResultMessage
|
||||
state: 'pending' | 'failed'
|
||||
state: 'pending' | 'failed' | 'terminal'
|
||||
attempts: number
|
||||
createdAt: number
|
||||
}> {
|
||||
@@ -2252,7 +2297,6 @@ export class AssistantDatabase {
|
||||
) AS cumulative_bytes
|
||||
FROM channel_outbox
|
||||
WHERE state != 'delivered'
|
||||
AND attempts < 5
|
||||
${channel === undefined ? '' : 'AND channel = ?'}
|
||||
)
|
||||
SELECT id, message_json, state, attempts, created_at
|
||||
@@ -2272,7 +2316,7 @@ export class AssistantDatabase {
|
||||
) as Array<{
|
||||
id: string
|
||||
message_json: string
|
||||
state: 'pending' | 'failed'
|
||||
state: 'pending' | 'failed' | 'terminal'
|
||||
attempts: number
|
||||
created_at: number
|
||||
}>
|
||||
@@ -5340,32 +5384,46 @@ export class AssistantDatabase {
|
||||
]!
|
||||
).toISOString()
|
||||
: null
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE heartbeat_runs
|
||||
SET status = 'failed', next_attempt_at = ?,
|
||||
completed_at = ?, error = ?, lease_owner = NULL,
|
||||
lease_expires_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND status = 'claimed' AND lease_owner = ?`
|
||||
)
|
||||
.run(
|
||||
nextAttemptAt,
|
||||
timestamp,
|
||||
error.slice(0, 2_000),
|
||||
timestamp,
|
||||
claim.run.id,
|
||||
claim.leaseOwner
|
||||
)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('Heartbeat lease is no longer active')
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE heartbeat_runs
|
||||
SET status = 'failed', next_attempt_at = ?,
|
||||
completed_at = ?, error = ?, lease_owner = NULL,
|
||||
lease_expires_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND config_id = ?
|
||||
AND status = 'claimed' AND lease_owner = ?
|
||||
AND lease_expires_at > ?`
|
||||
)
|
||||
.run(
|
||||
nextAttemptAt,
|
||||
timestamp,
|
||||
error.slice(0, 2_000),
|
||||
timestamp,
|
||||
claim.run.id,
|
||||
claim.config.id,
|
||||
claim.leaseOwner,
|
||||
timestamp
|
||||
)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('Heartbeat lease is no longer active')
|
||||
}
|
||||
const configResult = database
|
||||
.prepare(
|
||||
`UPDATE heartbeat_configs
|
||||
SET last_status = 'failed', updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(timestamp, claim.config.id)
|
||||
if (configResult.changes !== 1) {
|
||||
throw new Error('Heartbeat config no longer exists')
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (transactionError) {
|
||||
database.exec('ROLLBACK')
|
||||
throw transactionError
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE heartbeat_configs
|
||||
SET last_status = 'failed', updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(timestamp, claim.config.id)
|
||||
return this.getHeartbeatRun(claim.run.id)
|
||||
}
|
||||
|
||||
@@ -5741,12 +5799,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 23) {
|
||||
if (version.user_version > 25) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 23) {
|
||||
if (version.user_version === 25) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -5760,6 +5818,8 @@ export class AssistantDatabase {
|
||||
default_work_mode TEXT NOT NULL
|
||||
CHECK(default_work_mode IN ('ask', 'execute')),
|
||||
runtime_selection_json TEXT,
|
||||
built_in_default INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(built_in_default IN (0, 1)),
|
||||
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -7001,6 +7061,122 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 24) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec(`
|
||||
ALTER TABLE channel_outbox
|
||||
RENAME TO channel_outbox_legacy;
|
||||
DROP INDEX IF EXISTS channel_outbox_state_created;
|
||||
CREATE TABLE channel_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
message_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL
|
||||
CHECK(
|
||||
state IN (
|
||||
'pending', 'delivered', 'failed', 'terminal'
|
||||
)
|
||||
),
|
||||
attempts INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO channel_outbox
|
||||
(id, channel, event_id, message_json, state, attempts,
|
||||
created_at)
|
||||
SELECT id, channel, event_id,
|
||||
CASE
|
||||
WHEN state = 'failed' AND attempts >= 5
|
||||
THEN json_remove(message_json, '$.attachments')
|
||||
ELSE message_json
|
||||
END,
|
||||
CASE
|
||||
WHEN state = 'failed' AND attempts >= 5
|
||||
THEN 'terminal'
|
||||
ELSE state
|
||||
END,
|
||||
attempts, created_at
|
||||
FROM channel_outbox_legacy;
|
||||
DROP TABLE channel_outbox_legacy;
|
||||
CREATE INDEX channel_outbox_state_created
|
||||
ON channel_outbox(state, created_at);
|
||||
PRAGMA user_version = 24;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 25) {
|
||||
const projectColumns = new Set(
|
||||
(
|
||||
database.prepare('PRAGMA table_info(projects)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).map((column) => column.name)
|
||||
)
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (!projectColumns.has('built_in_default')) {
|
||||
database.exec(`
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN built_in_default INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(built_in_default IN (0, 1));
|
||||
`)
|
||||
}
|
||||
database.exec('UPDATE projects SET built_in_default = 0')
|
||||
const legacyCandidates = database
|
||||
.prepare(
|
||||
`SELECT id
|
||||
FROM projects
|
||||
WHERE name = ?
|
||||
AND description = ?
|
||||
AND kind = 'user'
|
||||
AND channel IS NULL
|
||||
AND status = 'active'
|
||||
AND default_work_mode = 'ask'
|
||||
AND runtime_selection_json IS NULL
|
||||
AND created_at = updated_at
|
||||
LIMIT 2`
|
||||
)
|
||||
.all(
|
||||
builtInDefaultProjectSeedName,
|
||||
builtInDefaultProjectSeedDescription
|
||||
) as Array<{ id: string }>
|
||||
const originalProject = database
|
||||
.prepare(
|
||||
`SELECT id
|
||||
FROM projects
|
||||
WHERE rowid = 1`
|
||||
)
|
||||
.get() as { id: string } | undefined
|
||||
if (
|
||||
legacyCandidates.length === 1 &&
|
||||
legacyCandidates[0]!.id === originalProject?.id
|
||||
) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE projects
|
||||
SET built_in_default = 1
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(legacyCandidates[0]!.id)
|
||||
}
|
||||
database.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
projects_built_in_default_unique
|
||||
ON projects(built_in_default)
|
||||
WHERE built_in_default = 1;
|
||||
PRAGMA user_version = 25;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(23)
|
||||
expect(version).toBe(25)
|
||||
expect(heartbeatTableCount).toBe(4)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user