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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user