feat: add persistent desktop assistant workspace

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-31 22:33:03 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 698a15ad14
commit 6ef1795b81
101 changed files with 31866 additions and 1176 deletions
@@ -0,0 +1,233 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createDatabase(): Promise<AssistantDatabase> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-'))
temporaryDirectories.push(directory)
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize('C:\\Workspace')
return database
}
describe('AssistantDatabase', () => {
it('creates a default project and persists project updates', async () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
expect(defaultProject).toMatchObject({
name: '默认项目',
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
status: 'active'
})
expect(database.listExperts()).toHaveLength(3)
const project = database.createProject({
name: '产品发布',
description: '发布资料和任务',
rootPath: 'C:\\Release',
defaultWorkMode: 'plan'
})
expect(database.listProjects()).toHaveLength(2)
const updated = database.updateProject(project.id, {
name: '产品发布 2',
description: '更新后的项目',
rootPath: 'C:\\Release',
defaultWorkMode: 'execute'
})
expect(updated).toMatchObject({
name: '产品发布 2',
defaultWorkMode: 'execute'
})
database.setProjectArchived(project.id, true)
expect(database.listProjects()).toHaveLength(1)
expect(database.listProjects(true)).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: project.id,
status: 'archived'
})
])
)
database.close()
})
it('persists task lifecycle and events', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
const taskId = '00000000-0000-4000-8000-000000000201'
database.createTask({
id: taskId,
projectId: project.id,
conversationId: 'conversation-1',
title: '整理发布说明',
instructions: '根据本次变更整理说明',
workMode: 'execute'
})
expect(database.listTasks()[0]).toMatchObject({
id: taskId,
status: 'running',
projectId: project.id
})
database.updateTaskStatus(taskId, 'waiting_approval')
expect(database.listTasks()[0]).toMatchObject({
status: 'waiting_approval'
})
database.updateTaskStatus(taskId, 'completed')
expect(database.listTasks()[0]).toMatchObject({
status: 'completed',
completedAt: expect.any(String)
})
const artifact = database.createTextArtifact({
projectId: project.id,
taskId,
title: '发布说明',
content: '# 发布说明\n\n内容'
})
expect(database.listArtifacts(project.id)).toEqual([
expect.objectContaining({
id: artifact.id,
kind: 'markdown',
content: '# 发布说明\n\n内容'
})
])
const memory = database.createMemory({
scope: 'project',
scopeId: project.id,
type: 'preference',
content: '使用简洁中文回复'
})
expect(database.listMemories(project.id)).toEqual([
expect.objectContaining({
id: memory.id,
status: 'confirmed',
content: '使用简洁中文回复'
})
])
database.removeMemory(memory.id)
expect(database.listMemories(project.id)).toEqual([])
const schedule = database.createSchedule({
projectId: project.id,
title: '每日摘要',
prompt: '总结今天的任务状态',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2026-07-31T00:00:00.000Z'
})
expect(
database.claimDueSchedules(new Date('2026-07-31T00:01:00.000Z'))
).toEqual([expect.objectContaining({ id: schedule.id })])
expect(database.listSchedules(project.id)[0]).toMatchObject({
id: schedule.id,
nextRunAt: '2026-08-01T00:00:00.000Z',
lastRunAt: '2026-07-31T00:01:00.000Z'
})
const overdue = database.createSchedule({
projectId: project.id,
title: '过期摘要',
prompt: '总结任务状态',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2025-07-31T00:00:00.000Z'
})
database.claimDueSchedules(
new Date('2026-07-31T00:01:00.000Z')
)
expect(
database
.listSchedules(project.id)
.find((item) => item.id === overdue.id)
).toMatchObject({
nextRunAt: '2026-08-01T00:00:00.000Z'
})
database.close()
})
it('replaces and restores bounded conversation snapshots', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
const conversationId = '00000000-0000-4000-8000-000000000211'
database.replaceConversations([
{
id: conversationId,
projectId: project.id,
title: '发布讨论',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000212',
role: 'user',
content: '整理发布说明',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: '00000000-0000-4000-8000-000000000213',
role: 'assistant',
content: '处理中',
createdAt: 1_775_000_001_000,
state: 'streaming'
}
]
}
])
expect(database.listConversations()).toEqual([
expect.objectContaining({
id: conversationId,
projectId: project.id,
messages: [
expect.objectContaining({ role: 'user', state: 'complete' }),
expect.objectContaining({
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断')
})
]
})
])
database.replaceConversations([])
expect(database.listConversations()).toEqual([])
database.close()
})
it('persists remote delegation results until delivery succeeds', async () => {
const database = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000221'
database.saveDelegationResult(taskId, {
status: 'completed',
output: '远程结果'
})
expect(database.listPendingDelegationResults()).toEqual([
{
taskId,
result: {
status: 'completed',
output: '远程结果'
}
}
])
database.markDelegationDelivered(taskId)
expect(database.listPendingDelegationResults()).toEqual([])
expect(database.getDelegationDeliveryStatus(taskId)).toBe(
'delivered'
)
database.close()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from 'vitest'
import { RemoteDelegationService } from './remote-delegation-service'
describe('RemoteDelegationService', () => {
it('polls a public HTTPS endpoint and posts a bounded result', async () => {
const transport = vi
.fn()
.mockResolvedValueOnce({
status: 200,
body: JSON.stringify({
id: '00000000-0000-4000-8000-000000000301',
title: '远程摘要',
prompt: '整理状态',
workMode: 'ask'
})
})
.mockResolvedValueOnce({ status: 204, body: '' })
const onTask = vi.fn(async () => ({
status: 'completed' as const,
output: '完成'
}))
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
transport,
onTask
})
await service.pollOnce()
expect(onTask).toHaveBeenCalledOnce()
expect(transport).toHaveBeenLastCalledWith(
expect.objectContaining({
pathname:
'/goodbuddy/tasks/00000000-0000-4000-8000-000000000301/result'
}),
expect.any(Object),
'test-token',
'POST',
expect.any(AbortSignal),
expect.stringContaining('"completed"')
)
})
it('retries result delivery without executing the task twice', async () => {
const task = {
id: '00000000-0000-4000-8000-000000000302',
title: '远程摘要',
prompt: '整理状态',
workMode: 'plan'
}
const transport = vi
.fn()
.mockResolvedValueOnce({
status: 200,
body: JSON.stringify(task)
})
.mockResolvedValueOnce({ status: 503, body: '' })
.mockResolvedValueOnce({ status: 204, body: '' })
.mockResolvedValueOnce({ status: 204, body: '' })
const onTask = vi.fn(async () => ({
status: 'completed' as const,
output: '完成'
}))
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
transport,
onTask
})
await expect(service.pollOnce()).rejects.toThrow('结果提交失败')
await service.pollOnce()
expect(onTask).toHaveBeenCalledOnce()
expect(
transport.mock.calls.filter((call) => call[3] === 'POST')
).toHaveLength(2)
})
it('drains a durable outbox before accepting another task', async () => {
const records = new Map<
string,
{
status: 'pending' | 'delivered'
result: {
status: 'completed' | 'failed'
output?: string
error?: string
}
}
>([
[
'00000000-0000-4000-8000-000000000303',
{
status: 'pending',
result: { status: 'completed', output: '持久结果' }
}
]
])
const outbox = {
listPending: () =>
[...records.entries()]
.filter(([, value]) => value.status === 'pending')
.map(([taskId, value]) => ({ taskId, result: value.result })),
getStatus: (taskId: string) => records.get(taskId)?.status,
save: vi.fn(),
markDelivered: (taskId: string) => {
const value = records.get(taskId)
if (value) {
value.status = 'delivered'
}
}
}
const transport = vi
.fn()
.mockResolvedValueOnce({ status: 204, body: '' })
.mockResolvedValueOnce({ status: 204, body: '' })
const onTask = vi.fn()
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
transport,
onTask,
outbox
})
await service.pollOnce()
expect(onTask).not.toHaveBeenCalled()
expect(records.values().next().value?.status).toBe('delivered')
expect(transport.mock.calls[0]?.[3]).toBe('POST')
})
it('aborts an active request when stopped', async () => {
let observedSignal: AbortSignal | undefined
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
transport: async (_url, _address, _token, _method, signal) => {
observedSignal = signal
await new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(signal.reason),
{ once: true }
)
})
return { status: 204, body: '' }
},
onTask: vi.fn()
})
const polling = service.pollOnce()
await vi.waitFor(() => expect(observedSignal).toBeDefined())
service.stop()
await expect(polling).rejects.toBeDefined()
expect(observedSignal?.aborted).toBe(true)
})
it('rejects endpoints resolving to private networks', async () => {
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
transport: vi.fn(),
onTask: vi.fn()
})
await expect(service.pollOnce()).rejects.toThrow('私有或不安全网络')
})
})
@@ -0,0 +1,308 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { request as httpsRequest } from 'node:https'
import { z } from 'zod'
import { isPublicAddress } from '../knowledge/url-importer'
const remoteTaskSchema = z
.object({
id: z.string().uuid(),
projectId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(100_000),
workMode: z.enum(['ask', 'plan'])
})
.strict()
export type RemoteDelegationTask = z.infer<typeof remoteTaskSchema>
type RemoteResult = {
status: 'completed' | 'failed'
output?: string
error?: string
}
type ResolvedAddress = {
address: string
family: number
}
type RemoteTransport = (
url: URL,
address: ResolvedAddress,
token: string,
method: 'GET' | 'POST',
signal: AbortSignal,
body?: string
) => Promise<{ status: number; body: string }>
type RemoteDelegationOptions = {
endpoint: string
token: string
onTask: (task: RemoteDelegationTask) => Promise<RemoteResult>
lookup?: (hostname: string) => Promise<ResolvedAddress[]>
transport?: RemoteTransport
intervalMs?: number
outbox?: {
listPending: () => Array<{ taskId: string; result: RemoteResult }>
getStatus: (
taskId: string
) => 'pending' | 'delivered' | undefined
save: (taskId: string, result: RemoteResult) => void
markDelivered: (taskId: string) => void
}
}
function normalizeEndpoint(input: string): URL {
const url = new URL(input.trim())
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '' && url.pathname !== '/')
) {
throw new Error('远程委派地址必须是无凭据和路径的 HTTPS origin')
}
return url
}
async function defaultLookup(hostname: string): Promise<ResolvedAddress[]> {
return dnsLookup(hostname, { all: true, verbatim: true })
}
function defaultTransport(
url: URL,
address: ResolvedAddress,
token: string,
method: 'GET' | 'POST',
signal: AbortSignal,
body?: string
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
let settled = false
const fail = (error: Error): void => {
if (settled) {
return
}
settled = true
reject(error)
}
const request = httpsRequest(
url,
{
method,
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...(body
? { 'content-length': String(Buffer.byteLength(body)) }
: {})
},
lookup: (_hostname, _options, callback) => {
callback(null, address.address, address.family)
},
servername: url.hostname,
signal
},
(response) => {
const chunks: Buffer[] = []
let bytes = 0
response.on('data', (chunk: Buffer) => {
bytes += chunk.byteLength
if (bytes > 1024 * 1024) {
request.destroy(new Error('远程委派响应超过 1MB 限制'))
return
}
chunks.push(Buffer.from(chunk))
})
response.on('end', () => {
if (settled) {
return
}
settled = true
resolve({
status: response.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8')
})
})
response.on('aborted', () => {
fail(new Error('远程委派响应意外中断'))
})
response.on('error', fail)
}
)
request.setTimeout(15_000, () => {
request.destroy(new Error('远程委派请求超时'))
})
request.on('error', fail)
request.end(body)
})
}
export class RemoteDelegationService {
private readonly endpoint: URL
private readonly lookup: NonNullable<RemoteDelegationOptions['lookup']>
private readonly transport: RemoteTransport
private readonly deliveredIds = new Set<string>()
private readonly pendingResults = new Map<string, RemoteResult>()
private interval?: NodeJS.Timeout
private activeRequest?: AbortController
private polling = false
constructor(private readonly options: RemoteDelegationOptions) {
this.endpoint = normalizeEndpoint(options.endpoint)
if (!options.token.trim() || options.token.length > 8_192) {
throw new Error('远程委派 Token 无效')
}
this.lookup = options.lookup ?? defaultLookup
this.transport = options.transport ?? defaultTransport
}
start(): void {
if (this.interval) {
return
}
this.interval = setInterval(
() => void this.pollOnce().catch(() => undefined),
this.options.intervalMs ?? 60_000
)
void this.pollOnce().catch(() => undefined)
}
stop(): void {
if (this.interval) {
clearInterval(this.interval)
this.interval = undefined
}
this.activeRequest?.abort()
}
async pollOnce(): Promise<void> {
if (this.polling) {
return
}
this.polling = true
const controller = new AbortController()
this.activeRequest = controller
try {
const address = await this.resolvePublicAddress()
const durablePending = this.options.outbox?.listPending()[0]
const memoryPending = this.pendingResults.entries().next().value
const pending = durablePending
? ([durablePending.taskId, durablePending.result] as const)
: memoryPending
if (pending) {
await this.deliverResult(
pending[0],
pending[1],
address,
controller.signal
)
this.markDelivered(pending[0])
}
const nextUrl = new URL('/goodbuddy/tasks/next', this.endpoint)
const response = await this.transport(
nextUrl,
address,
this.options.token,
'GET',
controller.signal
)
if (response.status === 204) {
return
}
if (response.status !== 200) {
throw new Error(`远程委派服务返回 HTTP ${response.status}`)
}
const task = remoteTaskSchema.parse(JSON.parse(response.body))
if (
this.deliveredIds.has(task.id) ||
this.options.outbox?.getStatus(task.id) === 'delivered'
) {
return
}
const existingResult =
this.options.outbox
?.listPending()
.find((item) => item.taskId === task.id)?.result ??
this.pendingResults.get(task.id)
let result: RemoteResult
if (existingResult) {
result = existingResult
} else {
try {
result = await this.options.onTask(task)
} catch (error) {
result = {
status: 'failed',
error: error instanceof Error ? error.message : '远程任务执行失败'
}
}
if (this.options.outbox) {
this.options.outbox.save(task.id, result)
} else {
this.pendingResults.set(task.id, result)
}
}
await this.deliverResult(task.id, result, address, controller.signal)
this.markDelivered(task.id)
} finally {
if (this.activeRequest === controller) {
this.activeRequest = undefined
}
this.polling = false
}
}
private async deliverResult(
taskId: string,
result: RemoteResult,
address: ResolvedAddress,
signal: AbortSignal
): Promise<void> {
const resultUrl = new URL(
`/goodbuddy/tasks/${encodeURIComponent(taskId)}/result`,
this.endpoint
)
const response = await this.transport(
resultUrl,
address,
this.options.token,
'POST',
signal,
JSON.stringify({
status: result.status,
output: result.output?.slice(0, 1_000_000),
error: result.error?.slice(0, 2_000)
})
)
if (response.status < 200 || response.status >= 300) {
throw new Error(`远程委派结果提交失败(HTTP ${response.status}`)
}
}
private markDelivered(taskId: string): void {
this.pendingResults.delete(taskId)
this.options.outbox?.markDelivered(taskId)
this.deliveredIds.add(taskId)
if (this.deliveredIds.size > 1_000) {
const oldest = this.deliveredIds.values().next().value
if (oldest) {
this.deliveredIds.delete(oldest)
}
}
}
private async resolvePublicAddress(): Promise<ResolvedAddress> {
const addresses = await this.lookup(this.endpoint.hostname)
const address = addresses.find((candidate) =>
isPublicAddress(candidate.address)
)
if (!address || addresses.some((candidate) => !isPublicAddress(candidate.address))) {
throw new Error('远程委派地址解析到私有或不安全网络')
}
return address
}
}
@@ -0,0 +1,64 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { getWorkspaceChanges } from './workspace-changes-service'
const execute = promisify(execFile)
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('getWorkspaceChanges', () => {
it('returns tracked and untracked Git workspace changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
await execute('git', ['init'], { cwd: directory })
await writeFile(join(directory, 'tracked.txt'), 'before\n')
await execute('git', ['add', 'tracked.txt'], { cwd: directory })
await execute(
'git',
[
'-c',
'user.name=GoodBuddy Test',
'-c',
'user.email=test@goodbuddy.invalid',
'commit',
'-m',
'initial'
],
{ cwd: directory }
)
await writeFile(join(directory, 'tracked.txt'), 'after\n')
await writeFile(join(directory, 'new.txt'), 'new\n')
const changes = await getWorkspaceChanges(directory)
expect(changes).toMatchObject({
available: true,
truncated: false
})
expect(changes.status).toContain('M tracked.txt')
expect(changes.status).toContain('?? new.txt')
expect(changes.patch).toContain('-before')
expect(changes.patch).toContain('+after')
})
it('fails safely for a non-Git directory', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
const changes = await getWorkspaceChanges(directory)
expect(changes.available).toBe(false)
expect(changes.error).toBeTruthy()
})
})
@@ -0,0 +1,113 @@
import spawn from 'cross-spawn'
import type { WorkspaceChanges } from '../../shared/assistant-contracts'
const MAX_OUTPUT_BYTES = 512 * 1024
const COMMAND_TIMEOUT_MS = 10_000
type CommandResult = {
code: number | null
stdout: string
stderr: string
truncated: boolean
}
function runGit(
rootPath: string,
args: string[]
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: rootPath,
shell: false,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let bytes = 0
let truncated = false
const capture = (target: Buffer[], chunk: Buffer | string): void => {
const buffer = Buffer.from(chunk)
const remaining = MAX_OUTPUT_BYTES - bytes
if (remaining <= 0) {
truncated = true
return
}
target.push(buffer.subarray(0, remaining))
bytes += Math.min(buffer.byteLength, remaining)
truncated ||= buffer.byteLength > remaining
}
child.stdout?.on('data', (chunk: Buffer | string) =>
capture(stdout, chunk)
)
child.stderr?.on('data', (chunk: Buffer | string) =>
capture(stderr, chunk)
)
const timeout = setTimeout(() => {
child.kill()
reject(new Error('读取文件更改超时'))
}, COMMAND_TIMEOUT_MS)
child.once('error', (error) => {
clearTimeout(timeout)
reject(error)
})
child.once('close', (code) => {
clearTimeout(timeout)
resolve({
code,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
truncated
})
})
})
}
export async function getWorkspaceChanges(
rootPath: string
): Promise<WorkspaceChanges> {
if (!rootPath.trim()) {
return {
rootPath,
available: false,
status: '',
patch: '',
truncated: false,
error: '项目尚未配置工作区目录'
}
}
try {
const [status, patch] = await Promise.all([
runGit(rootPath, ['status', '--short', '--untracked-files=normal']),
runGit(rootPath, ['diff', '--no-ext-diff', '--no-color', 'HEAD'])
])
if (status.code !== 0 || patch.code !== 0) {
const detail = status.stderr || patch.stderr
return {
rootPath,
available: false,
status: '',
patch: '',
truncated: status.truncated || patch.truncated,
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
}
}
return {
rootPath,
available: true,
status: status.stdout,
patch: patch.stdout,
truncated: status.truncated || patch.truncated
}
} catch (error) {
return {
rootPath,
available: false,
status: '',
patch: '',
truncated: false,
error:
error instanceof Error ? error.message : '无法读取 Git 工作区'
}
}
}