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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
698a15ad14
commit
6ef1795b81
@@ -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 工作区'
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user