chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
This commit is contained in:
@@ -234,6 +234,97 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('safely deletes a confirmed project and its scoped data', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.createProject({
|
||||
name: '待删除项目',
|
||||
description: '删除测试',
|
||||
rootPath: 'C:\\Delete',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
const conversationId = '00000000-0000-4000-8000-000000000111'
|
||||
const taskId = '00000000-0000-4000-8000-000000000211'
|
||||
database.replaceConversations([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
title: '项目对话',
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
}
|
||||
])
|
||||
database.createTask({
|
||||
id: taskId,
|
||||
projectId: project.id,
|
||||
conversationId,
|
||||
title: '项目任务',
|
||||
instructions: '执行任务',
|
||||
workMode: 'execute'
|
||||
})
|
||||
database.createTextArtifact({
|
||||
projectId: project.id,
|
||||
taskId,
|
||||
title: '项目成果',
|
||||
content: '内容'
|
||||
})
|
||||
database.createMemory({
|
||||
scope: 'project',
|
||||
scopeId: project.id,
|
||||
type: 'fact',
|
||||
content: '项目记忆'
|
||||
})
|
||||
database.createSchedule({
|
||||
projectId: project.id,
|
||||
title: '项目计划',
|
||||
prompt: '执行计划',
|
||||
workMode: 'ask',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-08T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, project.name)
|
||||
).toThrow('项目仍有进行中的任务')
|
||||
database.updateTaskStatus(taskId, 'completed')
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, '错误名称')
|
||||
).toThrow('项目名称确认不匹配')
|
||||
|
||||
database.deleteProject(project.id, project.name)
|
||||
|
||||
expect(
|
||||
database.listProjects(true).some((item) => item.id === project.id)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database.listConversations().some(
|
||||
(conversation) => conversation.projectId === project.id
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database.listTasks().some((task) => task.projectId === project.id)
|
||||
).toBe(false)
|
||||
expect(database.listArtifacts(project.id)).toEqual([])
|
||||
expect(database.listSchedules(project.id)).toEqual([])
|
||||
expect(
|
||||
database
|
||||
.listMemories(project.id)
|
||||
.some((memory) => memory.scopeId === project.id)
|
||||
).toBe(false)
|
||||
expect(database.listProjects()).toHaveLength(1)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('does not delete the final active project', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, project.name)
|
||||
).toThrow('至少需要保留一个可用项目')
|
||||
expect(database.listProjects()).toHaveLength(1)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('creates, updates, and soft-deletes expert roles', async () => {
|
||||
const database = await createDatabase()
|
||||
const expert = database.createExpert({
|
||||
@@ -613,6 +704,29 @@ describe('AssistantDatabase', () => {
|
||||
id: '00000000-0000-4000-8000-000000000213',
|
||||
role: 'assistant',
|
||||
content: '处理中',
|
||||
reasoning: '先分析发布范围',
|
||||
blocks: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000217',
|
||||
type: 'reasoning',
|
||||
content: '先分析发布范围'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000218',
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
state: 'running',
|
||||
summary: 'OpenCode 工具:read'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000219',
|
||||
type: 'text',
|
||||
content: '处理中'
|
||||
}
|
||||
],
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'streaming',
|
||||
artifactIds: [
|
||||
@@ -665,6 +779,24 @@ describe('AssistantDatabase', () => {
|
||||
role: 'assistant',
|
||||
state: 'error',
|
||||
status: expect.stringContaining('意外中断'),
|
||||
reasoning: '先分析发布范围',
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
content: '先分析发布范围'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
tool: expect.objectContaining({
|
||||
callId: 'call-1',
|
||||
state: 'interrupted'
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
content: '处理中'
|
||||
})
|
||||
],
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000216'
|
||||
],
|
||||
|
||||
@@ -89,6 +89,8 @@ type MessageRow = {
|
||||
type MessageMetadata = {
|
||||
createdAt?: number
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
@@ -579,6 +581,20 @@ function interruptActiveTools(
|
||||
)
|
||||
}
|
||||
|
||||
function interruptActiveToolBlocks(
|
||||
blocks: MessageMetadata['blocks']
|
||||
): MessageMetadata['blocks'] {
|
||||
return blocks?.map((block) =>
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' || block.tool.state === 'running')
|
||||
? {
|
||||
...block,
|
||||
tool: { ...block.tool, state: 'interrupted' as const }
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
|
||||
export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
|
||||
@@ -714,7 +730,13 @@ export class AssistantDatabase {
|
||||
metadata.tools?.some(
|
||||
(tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running'
|
||||
)
|
||||
) ||
|
||||
metadata.blocks?.some(
|
||||
(block) =>
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' ||
|
||||
block.tool.state === 'running')
|
||||
)
|
||||
)
|
||||
if (message.state !== 'streaming' && !hasActiveTool) {
|
||||
continue
|
||||
@@ -727,7 +749,8 @@ export class AssistantDatabase {
|
||||
message.state === 'streaming'
|
||||
? interruptedMessageStatus
|
||||
: metadata.status,
|
||||
tools: interruptActiveTools(metadata.tools)
|
||||
tools: interruptActiveTools(metadata.tools),
|
||||
blocks: interruptActiveToolBlocks(metadata.blocks)
|
||||
}),
|
||||
message.id
|
||||
)
|
||||
@@ -859,6 +882,96 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
deleteProject(projectId: string, confirmation: string): void {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const project = database
|
||||
.prepare('SELECT name, status FROM projects WHERE id = ?')
|
||||
.get(projectId) as
|
||||
| { name: string; status: AssistantProject['status'] }
|
||||
| undefined
|
||||
if (!project) {
|
||||
throw new Error('项目不存在')
|
||||
}
|
||||
if (confirmation !== project.name) {
|
||||
throw new Error('项目名称确认不匹配')
|
||||
}
|
||||
const activeProjectCount = database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM projects
|
||||
WHERE status = 'active'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
if (project.status === 'active' && activeProjectCount.count <= 1) {
|
||||
throw new Error('至少需要保留一个可用项目')
|
||||
}
|
||||
const activeTaskCount = database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM tasks
|
||||
WHERE project_id = ?
|
||||
AND status IN ('queued', 'running', 'waiting_approval', 'paused')`
|
||||
)
|
||||
.get(projectId) as { count: number }
|
||||
if (activeTaskCount.count > 0) {
|
||||
throw new Error('项目仍有进行中的任务,请先停止任务')
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM notifications
|
||||
WHERE task_id IN (
|
||||
SELECT id FROM tasks WHERE project_id = ?
|
||||
) OR schedule_id IN (
|
||||
SELECT id FROM schedules WHERE project_id = ?
|
||||
)`
|
||||
)
|
||||
.run(projectId, projectId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM delegation_outbox
|
||||
WHERE task_id IN (
|
||||
SELECT id FROM tasks WHERE project_id = ?
|
||||
)`
|
||||
)
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM memory_items
|
||||
WHERE (scope = 'project' AND scope_id = ?)
|
||||
OR (scope = 'conversation' AND scope_id IN (
|
||||
SELECT id FROM conversations WHERE project_id = ?
|
||||
))`
|
||||
)
|
||||
.run(projectId, projectId)
|
||||
database
|
||||
.prepare('DELETE FROM heartbeat_configs WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM artifacts WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM tasks WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM conversations WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM schedules WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
const result = database
|
||||
.prepare('DELETE FROM projects WHERE id = ?')
|
||||
.run(projectId)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('项目不存在')
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
listConversations(): ConversationSnapshot[] {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
@@ -897,6 +1010,10 @@ export class AssistantDatabase {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: metadata.reasoning,
|
||||
blocks: interrupted
|
||||
? interruptActiveToolBlocks(metadata.blocks)
|
||||
: metadata.blocks,
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(message.created_at),
|
||||
state: interrupted ? ('error' as const) : message.state,
|
||||
@@ -1006,6 +1123,8 @@ export class AssistantDatabase {
|
||||
JSON.stringify({
|
||||
createdAt: message.createdAt,
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('getWorkspaceChanges', () => {
|
||||
expect(changes.patch).toContain('+after')
|
||||
})
|
||||
|
||||
it('fails safely for a non-Git directory', async () => {
|
||||
it('keeps file browsing available without reporting Git errors', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('getWorkspaceChanges', () => {
|
||||
|
||||
expect(changes.available).toBe(false)
|
||||
expect(changes.files).toEqual([])
|
||||
expect(changes.error).toBeTruthy()
|
||||
expect(changes.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { basename, extname } from 'node:path'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
@@ -164,6 +165,15 @@ async function resolveWorkspacePath(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceEntryPath(
|
||||
rootPath: string,
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<string> {
|
||||
return (await resolveWorkspacePath(rootPath, inputPath, expected))
|
||||
.canonicalPath
|
||||
}
|
||||
|
||||
function parseChangedFiles(status: string): {
|
||||
files: WorkspaceChangedFile[]
|
||||
truncated: boolean
|
||||
@@ -223,6 +233,19 @@ export async function getWorkspaceChanges(
|
||||
error: '项目尚未配置工作区目录'
|
||||
}
|
||||
}
|
||||
const gitMetadata = await stat(join(rootPath, '.git')).catch(
|
||||
() => undefined
|
||||
)
|
||||
if (!gitMetadata) {
|
||||
return {
|
||||
rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
try {
|
||||
const [status, patch] = await Promise.all([
|
||||
runGit(rootPath, [
|
||||
|
||||
Reference in New Issue
Block a user