feat: expand secure assistant workflows

Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 10:04:59 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6ef1795b81
commit b3fdf96962
82 changed files with 17608 additions and 825 deletions
+685 -2
View File
@@ -1,6 +1,7 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
@@ -23,6 +24,77 @@ async function createDatabase(): Promise<AssistantDatabase> {
}
describe('AssistantDatabase', () => {
it('migrates existing databases to schema version 5', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const oldDatabase = new DatabaseSync(databasePath)
oldDatabase.exec(`
DROP TABLE model_usage_calls;
PRAGMA user_version = 3;
`)
oldDatabase.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
migrated.close()
const current = new DatabaseSync(databasePath)
expect(
(
current.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
).toBe(5)
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'model_usage_calls'`
)
.get()
).toEqual({ name: 'model_usage_calls' })
const foreignKeys = current
.prepare('PRAGMA foreign_key_list(model_usage_calls)')
.all() as Array<{
table: string
from: string
to: string
on_delete: string
}>
expect(foreignKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: 'tasks',
from: 'request_id',
to: 'id',
on_delete: 'CASCADE'
})
])
)
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'index'
AND name IN ('tasks_status_idx', 'messages_state_idx')
ORDER BY name`
)
.all()
).toEqual([
{ name: 'messages_state_idx' },
{ name: 'tasks_status_idx' }
])
current.close()
})
it('creates a default project and persists project updates', async () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
@@ -158,6 +230,110 @@ describe('AssistantDatabase', () => {
database.close()
})
it('durably interrupts active tasks with completion times and audit events on startup', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-recovery-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const runningTaskId =
'00000000-0000-4000-8000-000000000202'
const approvalTaskId =
'00000000-0000-4000-8000-000000000203'
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.createTask({
id: runningTaskId,
title: '运行中的任务',
instructions: '等待启动恢复',
workMode: 'execute'
})
initial.createTask({
id: approvalTaskId,
title: '等待审批的任务',
instructions: '等待启动恢复',
workMode: 'execute'
})
initial.updateTaskStatus(approvalTaskId, 'waiting_approval')
initial.close()
const recovered = new AssistantDatabase(databasePath)
recovered.initialize('C:\\Workspace')
const recoveredTasks = recovered
.listTasks()
.filter((task) =>
[runningTaskId, approvalTaskId].includes(task.id)
)
expect(recoveredTasks).toHaveLength(2)
expect(recoveredTasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: runningTaskId,
status: 'interrupted',
completedAt: expect.any(String),
error: '应用退出时任务仍在运行'
}),
expect.objectContaining({
id: approvalTaskId,
status: 'interrupted',
completedAt: expect.any(String),
error: '应用退出时任务仍在运行'
})
])
)
recovered.close()
const reopenedAgain = new AssistantDatabase(databasePath)
reopenedAgain.initialize('C:\\Workspace')
expect(
reopenedAgain
.listTasks()
.filter((task) =>
[runningTaskId, approvalTaskId].includes(task.id)
)
).toEqual(recoveredTasks)
reopenedAgain.close()
const durable = new DatabaseSync(databasePath)
const statusEvents = durable
.prepare(
`SELECT task_id, payload_json
FROM task_events
WHERE task_id IN (?, ?) AND kind = 'status'
ORDER BY task_id, id`
)
.all(runningTaskId, approvalTaskId) as Array<{
task_id: string
payload_json: string
}>
const recoveryEvents = statusEvents
.map((event) => ({
taskId: event.task_id,
payload: JSON.parse(event.payload_json) as {
status: string
error?: string
}
}))
.filter((event) => event.payload.status === 'interrupted')
expect(recoveryEvents).toEqual([
{
taskId: runningTaskId,
payload: {
status: 'interrupted',
error: '应用退出时任务仍在运行'
}
},
{
taskId: approvalTaskId,
payload: {
status: 'interrupted',
error: '应用退出时任务仍在运行'
}
}
])
durable.close()
})
it('replaces and restores bounded conversation snapshots', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
@@ -181,7 +357,22 @@ describe('AssistantDatabase', () => {
role: 'assistant',
content: '处理中',
createdAt: 1_775_000_001_000,
state: 'streaming'
state: 'streaming',
artifactIds: [
'00000000-0000-4000-8000-000000000216'
],
sourceReferences: [
{
libraryId: '00000000-0000-4000-8000-000000000214',
libraryName: '产品知识',
documentId: '00000000-0000-4000-8000-000000000215',
documentName: '发布说明.md',
sourceName: '发布目录',
snippet: '发布前需要完成验证。',
rank: -0.03,
retrievalChannels: ['fts', 'vector']
}
]
}
]
}
@@ -196,7 +387,16 @@ describe('AssistantDatabase', () => {
expect.objectContaining({
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断')
status: expect.stringContaining('意外中断'),
artifactIds: [
'00000000-0000-4000-8000-000000000216'
],
sourceReferences: [
expect.objectContaining({
documentName: '发布说明.md',
retrievalChannels: ['fts', 'vector']
})
]
})
]
})
@@ -206,6 +406,174 @@ describe('AssistantDatabase', () => {
database.close()
})
it('durably interrupts active tool metadata during startup recovery', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-conversation-recovery-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const conversationId =
'00000000-0000-4000-8000-000000000217'
const messageId = '00000000-0000-4000-8000-000000000218'
const cancelledMessageId =
'00000000-0000-4000-8000-000000000219'
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.replaceConversations([
{
id: conversationId,
title: '工具恢复',
updatedAt: 1_775_000_000_000,
messages: [
{
id: messageId,
role: 'assistant',
content: '工具仍在运行',
createdAt: 1_775_000_001_000,
state: 'streaming',
status: '正在执行工具',
tools: [
{
name: 'pending-tool',
state: 'pending',
summary: '等待调用'
},
{
name: 'running-tool',
state: 'running',
summary: '正在调用'
},
{
name: 'completed-tool',
state: 'completed',
summary: '调用完成'
},
{
name: 'failed-tool',
state: 'failed',
summary: '调用失败'
}
]
},
{
id: cancelledMessageId,
role: 'assistant',
content: '请求已取消',
createdAt: 1_775_000_002_000,
state: 'error',
status: '请求已取消',
tools: [
{
name: 'cancelled-tool',
state: 'running',
summary: '取消前仍在运行'
}
]
}
]
}
])
initial.close()
const recovered = new AssistantDatabase(databasePath)
recovered.initialize('C:\\Workspace')
expect(recovered.listConversations()[0]?.messages[0]).toMatchObject({
id: messageId,
state: 'error',
status: '上次运行意外中断,可以重新发送问题',
tools: [
expect.objectContaining({
name: 'pending-tool',
state: 'interrupted'
}),
expect.objectContaining({
name: 'running-tool',
state: 'interrupted'
}),
expect.objectContaining({
name: 'completed-tool',
state: 'completed'
}),
expect.objectContaining({ name: 'failed-tool', state: 'failed' })
]
})
expect(recovered.listConversations()[0]?.messages[1]).toMatchObject({
id: cancelledMessageId,
state: 'error',
status: '请求已取消',
tools: [
expect.objectContaining({
name: 'cancelled-tool',
state: 'interrupted'
})
]
})
recovered.close()
const durable = new DatabaseSync(databasePath)
const row = durable
.prepare(
`SELECT state, metadata_json
FROM messages
WHERE id = ?`
)
.get(messageId) as {
state: string
metadata_json: string
}
const metadata = JSON.parse(row.metadata_json) as {
status?: string
tools?: Array<{ name: string; state: string }>
}
expect(row.state).toBe('error')
expect(metadata.status).toBe(
'上次运行意外中断,可以重新发送问题'
)
expect(metadata.tools?.map((tool) => tool.state)).toEqual([
'interrupted',
'interrupted',
'completed',
'failed'
])
const cancelledRow = durable
.prepare(
`SELECT metadata_json
FROM messages
WHERE id = ?`
)
.get(cancelledMessageId) as { metadata_json: string }
expect(
(
JSON.parse(cancelledRow.metadata_json) as {
tools?: Array<{ state: string }>
}
).tools?.[0]?.state
).toBe('interrupted')
durable.close()
})
it('loads image artifact content only when requested by id', async () => {
const database = await createDatabase()
const artifact = database.createInlineArtifact({
kind: 'image',
title: '生成图片',
mimeType: 'image/png',
content: 'data:image/png;base64,iVBORw0KGgo='
})
expect(
database.listArtifacts().find((item) => item.id === artifact.id)
).toMatchObject({
id: artifact.id,
content: undefined
})
expect(database.getArtifact(artifact.id)).toMatchObject({
id: artifact.id,
content: 'data:image/png;base64,iVBORw0KGgo='
})
database.close()
})
it('persists remote delegation results until delivery succeeds', async () => {
const database = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000221'
@@ -230,4 +598,319 @@ describe('AssistantDatabase', () => {
)
database.close()
})
it('upserts absolute token usage snapshots idempotently', async () => {
const database = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000301'
database.createTask({
id: taskId,
title: '统计令牌',
instructions: '记录模型调用',
workMode: 'ask'
})
database.upsertModelUsageCall({
requestId: taskId,
callId: 'call-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 100,
output: 20,
cacheRead: 30,
cacheWrite: 10
})
database.upsertModelUsageCall({
requestId: taskId,
callId: 'call-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 12
})
expect(database.getTokenUsageSummary()).toEqual({
totals: {
callCount: 1,
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 12,
totalTokens: 150
},
records: [
expect.objectContaining({
requestId: taskId,
callCount: 1,
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 12,
totalTokens: 150
})
]
})
expect(() =>
database.upsertModelUsageCall({
requestId: taskId,
callId: 'negative',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: -1,
output: 0,
cacheRead: 0,
cacheWrite: 0
})
).toThrow('input must be a nonnegative safe integer')
expect(() =>
database.upsertModelUsageCall({
requestId: taskId,
callId: 'fractional',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 0,
output: 0.5,
cacheRead: 0,
cacheWrite: 0
})
).toThrow('output must be a nonnegative safe integer')
expect(() =>
database.upsertModelUsageCall({
requestId: taskId,
callId: 'call-2',
runtime: 'opencode',
provider: 'anthropic',
model: 'x'.repeat(501),
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0
})
).toThrow('model must contain between 1 and 500 characters')
database.close()
})
it('aggregates token usage with project and conversation metadata', async () => {
const database = await createDatabase()
const firstProject = database.listProjects()[0]!
const secondProject = database.createProject({
name: '第二项目',
description: '',
rootPath: 'C:\\Second',
defaultWorkMode: 'ask'
})
const firstConversationId =
'00000000-0000-4000-8000-000000000311'
const secondConversationId =
'00000000-0000-4000-8000-000000000312'
database.replaceConversations([
{
id: firstConversationId,
projectId: firstProject.id,
title: '第一会话',
updatedAt: 1_775_000_000_000,
messages: []
},
{
id: secondConversationId,
projectId: secondProject.id,
title: '第二会话',
updatedAt: 1_775_000_001_000,
messages: []
}
])
const firstTaskId = '00000000-0000-4000-8000-000000000321'
const secondTaskId = '00000000-0000-4000-8000-000000000322'
database.createTask({
id: firstTaskId,
projectId: firstProject.id,
conversationId: firstConversationId,
title: '第一请求',
instructions: '测试',
workMode: 'ask'
})
database.createTask({
id: secondTaskId,
projectId: secondProject.id,
conversationId: secondConversationId,
title: '第二请求',
instructions: '测试',
workMode: 'ask'
})
for (const usage of [
{
requestId: firstTaskId,
callId: 'call-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 100,
output: 40,
cacheRead: 30,
cacheWrite: 10
},
{
requestId: firstTaskId,
callId: 'call-2',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 50,
output: 20,
cacheRead: 5,
cacheWrite: 2
},
{
requestId: firstTaskId,
callId: 'call-3',
runtime: 'continue',
provider: 'openai',
model: 'gpt-5',
input: 80,
output: 30,
cacheRead: 0,
cacheWrite: 0
},
{
requestId: secondTaskId,
callId: 'call-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 25,
output: 15,
cacheRead: 7,
cacheWrite: 3
}
]) {
database.upsertModelUsageCall(usage)
}
const summary = database.getTokenUsageSummary()
expect(summary.totals).toEqual({
callCount: 4,
input: 255,
output: 105,
cacheRead: 42,
cacheWrite: 15,
totalTokens: 360
})
expect(summary.records).toHaveLength(3)
expect(summary.records).toEqual(
expect.arrayContaining([
expect.objectContaining({
requestId: firstTaskId,
projectId: firstProject.id,
projectName: firstProject.name,
conversationId: firstConversationId,
conversationTitle: '第一会话',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
callCount: 2,
input: 150,
output: 60,
cacheRead: 35,
cacheWrite: 12,
totalTokens: 210
}),
expect.objectContaining({
requestId: firstTaskId,
runtime: 'continue',
provider: 'openai',
model: 'gpt-5',
callCount: 1,
totalTokens: 110
}),
expect.objectContaining({
requestId: secondTaskId,
projectId: secondProject.id,
projectName: '第二项目',
conversationId: secondConversationId,
conversationTitle: '第二会话',
callCount: 1,
totalTokens: 40
})
])
)
database.close()
})
it('clears private assistant content while preserving workspace configuration', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
database.createMemory({
scope: 'project',
scopeId: project.id,
type: 'fact',
content: '待清除记忆'
})
database.createSchedule({
projectId: project.id,
title: '待清除任务',
prompt: '总结',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2026-08-02T00:00:00.000Z'
})
database.createHeartbeatConfig(
{
projectId: project.id,
name: '待清除心跳',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 48,
retentionDays: 90
},
new Date('2026-08-01T00:00:00.000Z')
)
const taskId = '00000000-0000-4000-8000-000000000331'
database.createTask({
id: taskId,
projectId: project.id,
title: '待清除用量',
instructions: '测试',
workMode: 'ask'
})
database.upsertModelUsageCall({
requestId: taskId,
callId: 'call-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
input: 10,
output: 5,
cacheRead: 2,
cacheWrite: 1
})
expect(database.getTokenUsageSummary().totals.totalTokens).toBe(15)
database.clearAssistantData()
expect(database.listProjects()).toHaveLength(1)
expect(database.listExperts()).toHaveLength(3)
expect(database.listMemories(project.id)).toEqual([])
expect(database.listSchedules(project.id)).toEqual([])
expect(database.listHeartbeatConfigs(project.id)).toEqual([])
expect(database.listTasks()).toEqual([])
expect(database.listArtifacts(project.id)).toEqual([])
expect(database.getTokenUsageSummary()).toEqual({
totals: {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0
},
records: []
})
database.close()
})
})