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:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
+421 -6
View File
@@ -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('远程对话不能作为本地对话删除')
+217 -41
View File
@@ -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)
})