feat: improve desktop reliability and customization

Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely.

Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility.

Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
@@ -0,0 +1,195 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
requestProcessTreeTermination,
terminateProcessTreeAndWait,
waitForProcessExit,
type WaitableProcessTreeChild
} from './child-process-termination'
function fakeChild(
pid = 42
): WaitableProcessTreeChild & EventEmitter {
const child =
new EventEmitter() as WaitableProcessTreeChild & EventEmitter
child.exitCode = null
child.pid = pid
child.kill = vi.fn()
return child
}
describe('child process tree termination', () => {
it('uses taskkill /T /F on Windows and bounds both exit waits', async () => {
vi.useFakeTimers()
try {
const child = fakeChild(314)
const killer = fakeChild(315)
killer.unref = vi.fn()
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
await vi.advanceTimersByTimeAsync(25)
await expect(termination).resolves.toBeUndefined()
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '314', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
expect(killer.unref).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
} finally {
vi.useRealTimers()
}
})
it('falls back to direct termination when Windows taskkill fails', async () => {
const child = fakeChild(314)
const killer = fakeChild(315)
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 1_000
})
killer.exitCode = 1
killer.emit('close', 1, null)
await Promise.resolve()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
child.exitCode = 0
child.emit('close', 0, null)
await expect(termination).resolves.toBeUndefined()
})
it('terminates a detached POSIX process group with the requested signal', () => {
const child = fakeChild(2718)
const killProcess = vi.fn()
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
killProcess
})
expect(killProcess).toHaveBeenCalledWith(-2718, 'SIGKILL')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('falls back to the direct child when POSIX group termination fails', () => {
const child = fakeChild(2718)
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
killProcess: vi.fn(() => {
throw new Error('not a group leader')
})
})
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
})
it('resolves exit waiting immediately on close', async () => {
const child = fakeChild()
const waiting = waitForProcessExit(child, 1_000)
child.emit('close', 0, null)
await expect(waiting).resolves.toBeUndefined()
})
it('does not terminate a child already marked as killed', () => {
const child = fakeChild()
child.killed = true
const spawnMock = vi.fn()
const killProcess = vi.fn()
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock,
killProcess
})
).toBeUndefined()
expect(spawnMock).not.toHaveBeenCalled()
expect(killProcess).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
})
it('supports utility-process handles without an exitCode', () => {
const child = {
killed: false,
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
const spawnMock = vi.fn(() => killer)
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock
})
).toBe(killer)
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '99', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
})
it('falls back asynchronously for a synchronous Windows caller', async () => {
vi.useFakeTimers()
try {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer),
signal: 'SIGKILL',
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
expect(child.kill).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
} finally {
vi.useRealTimers()
}
})
it('does not directly kill after successful Windows tree termination', () => {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer)
})
killer.exitCode = 0
killer.emit('close', 0, null)
expect(child.kill).not.toHaveBeenCalled()
})
})
+193
View File
@@ -0,0 +1,193 @@
import spawn from 'cross-spawn'
export type ProcessTreeChild = {
exitCode?: number | null
killed?: boolean
pid?: number
kill: (signal?: NodeJS.Signals) => unknown
unref?: () => unknown
}
export type WaitableProcessTreeChild = ProcessTreeChild & {
exitCode: number | null
once: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
removeListener?: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
}
export type ProcessTreeSpawn = (
command: string,
args: string[],
options: {
shell: false
stdio: 'ignore'
windowsHide: true
}
) => WaitableProcessTreeChild
export type ProcessGroupKill = (
pid: number,
signal: NodeJS.Signals
) => unknown
export type ProcessTreeTerminationOptions = {
platform?: NodeJS.Platform
spawn?: ProcessTreeSpawn
killProcess?: ProcessGroupKill
processGroup?: boolean
signal?: NodeJS.Signals
waitMs?: number
}
const DEFAULT_EXIT_WAIT_MS = 2_000
type ProcessExitResult = 'closed' | 'error' | 'timeout'
function monitorWindowsKiller(
killer: WaitableProcessTreeChild,
child: ProcessTreeChild,
signal: NodeJS.Signals,
waitMs: number
): void {
let settled = false
const fallback = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
killer.removeListener?.('close', onClose)
killer.removeListener?.('error', onError)
if (
!child.killed &&
(child.exitCode === undefined || child.exitCode === null)
) {
child.kill(signal)
}
}
const onClose = (): void => {
if (killer.exitCode === 0) {
settled = true
clearTimeout(timer)
killer.removeListener?.('error', onError)
return
}
fallback()
}
const onError = (): void => fallback()
const timer = setTimeout(fallback, waitMs)
timer.unref?.()
killer.once('close', onClose)
killer.once('error', onError)
}
function waitForProcessExitResult(
child: WaitableProcessTreeChild,
waitMs: number
): Promise<ProcessExitResult> {
if (child.exitCode !== null) {
return Promise.resolve('closed')
}
return new Promise((resolve) => {
const finish = (result: ProcessExitResult): void => {
clearTimeout(timer)
child.removeListener?.('close', onClose)
child.removeListener?.('error', onError)
resolve(result)
}
const onClose = (): void => finish('closed')
const onError = (): void => finish('error')
const timer = setTimeout(
() => finish('timeout'),
waitMs
)
timer.unref?.()
child.once('close', onClose)
child.once('error', onError)
})
}
export function waitForProcessExit(
child: WaitableProcessTreeChild,
waitMs = DEFAULT_EXIT_WAIT_MS
): Promise<void> {
return waitForProcessExitResult(child, waitMs).then(() => undefined)
}
export function requestProcessTreeTermination(
child: ProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): WaitableProcessTreeChild | undefined {
if (
(child.exitCode !== undefined && child.exitCode !== null) ||
child.killed
) {
return undefined
}
const platform = options.platform ?? process.platform
const signal = options.signal ?? 'SIGTERM'
if (platform === 'win32' && child.pid) {
try {
const killer = (options.spawn ?? spawn)(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref?.()
monitorWindowsKiller(
killer,
child,
signal,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
return killer
} catch {
child.kill(signal)
return undefined
}
}
if (options.processGroup && child.pid) {
try {
;(options.killProcess ?? process.kill)(-child.pid, signal)
if (child.exitCode === null) {
child.kill(signal)
}
return undefined
} catch {
// The child may not be a process-group leader. Fall back to the
// direct handle so cleanup is never weakened by that assumption.
}
}
child.kill(signal)
return undefined
}
export async function terminateProcessTreeAndWait(
child: WaitableProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): Promise<void> {
if (child.exitCode !== null) {
return
}
const exited = waitForProcessExit(
child,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
const killer = requestProcessTreeTermination(child, options)
if (killer) {
await waitForProcessExitResult(
killer,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
}
await exited
}
@@ -1124,6 +1124,263 @@ describe('ContinueHostAdapter', () => {
).rejects.toThrow('流式事件超过安全限制')
})
it.each([
{
label: 'event count',
limits: {
maximumStreamEvents: 1,
maximumStreamEventBytes: 10_000
}
},
{
label: 'event bytes',
limits: {
maximumStreamEvents: 10,
maximumStreamEventBytes: 60
}
}
])(
'enforces cumulative streamed $label across state polls',
async ({ limits }) => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [{ type: 'text', delta: '1234567890' }]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
...limits,
maximumToolCalls: 100,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('流式事件超过安全限制')
expect(forwarded).toEqual([
{ type: 'text', delta: '1234567890' }
])
}
)
it('enforces cumulative unique tool calls before forwarding a later batch', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [
{
type: 'tool',
callId: `call-${stateRequests}`,
name: 'Bash',
state: 'running'
}
]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 1,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('工具调用超过 100 个')
expect(forwarded).toHaveLength(1)
expect(forwarded[0]).toMatchObject({
type: 'tool',
tool: { callId: 'call-2' }
})
})
it('awaits bounded process cleanup before deleting the run directory', async () => {
const distribution = await createDistribution()
let globalDirectory = ''
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'CLEANUP_OK'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
})
}
return Response.json({})
})
)
let releaseTermination!: () => void
const terminateProcessTree = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseTermination = resolve
})
)
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: (_entry, _args, options) => {
globalDirectory =
options.env.CONTINUE_GLOBAL_DIR ?? ''
return {
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}
},
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 10,
terminateProcessTree
}
)
const run = adapter.run(
'hello',
new AbortController().signal,
async () => 'deny'
)
await vi.waitFor(() =>
expect(terminateProcessTree).toHaveBeenCalledOnce()
)
expect(globalDirectory).toBeTruthy()
expect(existsSync(globalDirectory)).toBe(true)
releaseTermination()
await expect(run).resolves.toEqual({ text: 'CLEANUP_OK' })
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
const distribution = await createDistribution()
let launchArgs: string[] = []
+105 -33
View File
@@ -50,6 +50,7 @@ import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response'
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -65,6 +66,7 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumToolCalls = 100
const maximumExecutionMilliseconds = 10 * 60_000
const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp'
@@ -239,6 +241,13 @@ export type ContinueHostAdapterOptions = {
skillPackages?: RuntimeSkillPackage[]
}
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEvents: number
maximumStreamEventBytes: number
maximumToolCalls: number
}
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute'
images?: AgentImage[]
@@ -504,8 +513,12 @@ export type ContinueHostChild = {
) => unknown
} | null
once: (
event: 'error',
listener: (error: Error) => void
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
removeListener?: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
kill: (signal?: NodeJS.Signals) => unknown
}
@@ -792,6 +805,10 @@ function extractUsageDelta(
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private readonly childTerminations = new WeakMap<
ContinueHostChild,
Promise<void>
>()
private readonly pendingQuestions = new Map<
string,
{
@@ -802,7 +819,20 @@ export class ContinueHostAdapter {
>()
private preparation?: Promise<PreparedHost>
constructor(private readonly options: ContinueHostAdapterOptions) {}
private readonly dependencies: ContinueHostAdapterDependencies
constructor(
private readonly options: ContinueHostAdapterOptions,
dependencies: Partial<ContinueHostAdapterDependencies> = {}
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEvents,
maximumStreamEventBytes,
maximumToolCalls,
...dependencies
}
}
private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) {
@@ -1515,17 +1545,20 @@ export class ContinueHostAdapter {
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) {
this.terminate(child)
void this.terminate(child)
}
})
const abort = (): void => {
this.terminate(child)
void this.terminate(child)
}
signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>()
let streamedText = false
let streamEventCount = 0
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
let executionTimeoutSignal: AbortSignal | undefined
try {
const initialState = await this.waitForStartup(
@@ -1585,17 +1618,55 @@ export class ContinueHostAdapter {
if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
const streamEventBytes = Buffer.byteLength(
JSON.stringify(state.goodbuddyEvents ?? [])
const streamEvents = state.goodbuddyEvents ?? []
const batchStreamEventBytes = Buffer.byteLength(
JSON.stringify(streamEvents)
)
if (streamEventBytes > maximumStreamEventBytes) {
const nextStreamEventCount =
streamEventCount + streamEvents.length
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
const nextToolCallIds = new Set(observedToolCallIds)
for (const event of streamEvents) {
if (event.type === 'tool') {
nextToolCallIds.add(event.callId)
}
}
if (
nextStreamEventCount >
this.dependencies.maximumStreamEvents ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
if (
nextToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
streamEventCount = nextStreamEventCount
streamEventBytes = nextStreamEventBytes
for (const callId of nextToolCallIds) {
observedToolCallIds.add(callId)
}
const historyTools = extractContinueTools(
state.session.history,
startIndex
)
for (const tool of historyTools) {
observedToolCallIds.add(tool.callId)
}
if (
observedToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedTools = mergeContinueTools(
observedTools,
extractContinueTools(state.session.history, startIndex)
historyTools
)
for (const event of state.goodbuddyEvents ?? []) {
for (const event of streamEvents) {
if (event.type === 'text') {
streamedText = true
await runOptions.onEvent?.(event)
@@ -1653,7 +1724,10 @@ export class ContinueHostAdapter {
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
if (handledPermissionIds.size >= 100) {
if (
handledPermissionIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
handledPermissionIds.add(pending.requestId)
@@ -1667,9 +1741,14 @@ export class ContinueHostAdapter {
if (
!observedTools.some((tool) => tool.callId === pendingCallId)
) {
if (observedTools.length >= 100) {
if (
!observedToolCallIds.has(pendingCallId) &&
observedToolCallIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedToolCallIds.add(pendingCallId)
observedTools = [
...observedTools,
{
@@ -1771,7 +1850,7 @@ export class ContinueHostAdapter {
signal: cleanupSignal
}).catch(() => undefined)
} finally {
this.terminate(child)
await this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
@@ -1795,31 +1874,24 @@ export class ContinueHostAdapter {
}
}
private terminate(child: ContinueHostChild): void {
if (child.exitCode !== null || child.killed) {
private async terminate(child: ContinueHostChild): Promise<void> {
const existing = this.childTerminations.get(child)
if (existing) {
await existing
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
const termination = this.dependencies
.terminateProcessTree(child)
.catch(() => undefined)
this.childTerminations.set(child, termination)
await termination
}
dispose(): void {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const child of this.children) {
this.terminate(child)
}
await Promise.all(
[...this.children].map((child) => this.terminate(child))
)
this.children.clear()
}
}
+24
View File
@@ -83,6 +83,7 @@ describe('ContinueAgentRuntime', () => {
text: 'Continue response'
})
mocks.respondHostQuestion.mockResolvedValue(undefined)
mocks.disposeHost.mockResolvedValue(undefined)
})
it('does not launch the CLI for an already-cancelled request', async () => {
@@ -104,6 +105,29 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.runHost).not.toHaveBeenCalled()
})
it('awaits host process cleanup during Runtime disposal', async () => {
let releaseDispose!: () => void
mocks.disposeHost.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseDispose = resolve
})
)
const runtime = createRuntime()
await collectEvents(runtime)
let disposed = false
const disposal = runtime.dispose().then(() => {
disposed = true
})
await Promise.resolve()
expect(disposed).toBe(false)
releaseDispose()
await disposal
expect(disposed).toBe(true)
})
it('uses the resolved binary through the Continue host adapter', async () => {
const runtime = createRuntime()
+3 -3
View File
@@ -784,9 +784,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const host of this.hostAdapters.values()) {
host.dispose()
}
await Promise.all(
[...this.hostAdapters.values()].map((host) => host.dispose())
)
this.hostAdapters.clear()
}
}
@@ -0,0 +1,88 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
createContinueUtilityProcessChild,
type ContinueUtilityProcessSource
} from './continue-utility-process-adapter'
import { waitForProcessExit } from './child-process-termination'
function createSource(): ContinueUtilityProcessSource & EventEmitter {
const emitter =
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
Object.defineProperties(emitter, {
pid: { value: 42 },
stderr: { value: undefined }
})
emitter.kill = vi.fn(() => true)
emitter.onExit = (listener) => {
emitter.on('exit', listener)
}
emitter.onceExit = (listener) => {
emitter.once('exit', listener)
}
emitter.onceError = (listener) => {
emitter.once('utility-error', listener)
}
emitter.removeExitListener = (listener) => {
emitter.removeListener('exit', listener)
}
emitter.removeErrorListener = (listener) => {
emitter.removeListener('utility-error', listener)
}
return emitter
}
describe('Continue utility process adapter', () => {
it('maps Electron exit to close and completes helper waits immediately', async () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
child.once('close', close)
const waiting = waitForProcessExit(child)
source.emit('exit', 0)
expect(close).toHaveBeenCalledWith(0)
expect(child.exitCode).toBe(0)
await expect(waiting).resolves.toBeUndefined()
})
it('maps utility errors and removes both listener types', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
const error = vi.fn()
child.once('close', close)
child.once('error', error)
child.removeListener?.('close', close)
child.removeListener?.('error', error)
source.emit('exit', 0)
source.emit('utility-error', 'FatalError', 'worker.js:1', 'report')
expect(close).not.toHaveBeenCalled()
expect(error).not.toHaveBeenCalled()
})
it('converts utility error details to a bounded Error', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const error = vi.fn()
child.once('error', error)
source.emit(
'utility-error',
'FatalError',
'worker.js:1',
'x'.repeat(1_000)
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/^Continue 宿worker\.js:1x{500}$/u
)
})
)
})
})
@@ -0,0 +1,96 @@
import type { ContinueHostChild } from './continue-host-adapter'
type UtilityErrorListener = (
type: 'FatalError',
location: string,
report: string
) => void
export type ContinueUtilityProcessSource = {
readonly pid?: number
readonly stderr?: ContinueHostChild['stderr']
kill(): boolean
onExit(listener: (code: number) => void): void
onceExit(listener: (code: number) => void): void
onceError(listener: UtilityErrorListener): void
removeExitListener(listener: (code: number) => void): void
removeErrorListener(listener: UtilityErrorListener): void
}
export function createContinueUtilityProcessChild(
utility: ContinueUtilityProcessSource
): ContinueHostChild {
let exitCode: number | null = null
let killed = false
const closeListeners = new Map<
(value: Error | number | null) => void,
(code: number) => void
>()
const errorListeners = new Map<
(value: Error | number | null) => void,
UtilityErrorListener
>()
utility.onExit((code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
get pid() {
return utility.pid
},
stderr: utility.stderr,
once: (event, listener) => {
if (event === 'close') {
const wrapped = (code: number): void => {
closeListeners.delete(listener)
listener(code)
}
closeListeners.set(listener, wrapped)
utility.onceExit(wrapped)
} else {
const wrapped: UtilityErrorListener = (
_type,
location,
report
): void => {
errorListeners.delete(listener)
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
}
errorListeners.set(listener, wrapped)
utility.onceError(wrapped)
}
return child
},
removeListener: (event, listener) => {
if (event === 'close') {
const wrapped = closeListeners.get(listener)
if (wrapped) {
closeListeners.delete(listener)
utility.removeExitListener(wrapped)
}
} else {
const wrapped = errorListeners.get(listener)
if (wrapped) {
errorListeners.delete(listener)
utility.removeErrorListener(wrapped)
}
}
return child
},
kill: () => {
killed = true
return utility.kill()
}
}
return child
}
+6 -52
View File
@@ -26,6 +26,7 @@ import type {
RuntimeExtensionCatalog,
RuntimeExtensionStoreDependencies
} from './runtime-extension-store'
import { terminateProcessTreeAndWait } from './child-process-termination'
const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
const NPM_SEARCH_PAGE_SIZE = 250
@@ -167,61 +168,14 @@ export type PackageManagerRunner = (
}
) => Promise<PackageManagerRunResult>
function waitForProcessClose(
child: ReturnType<typeof spawn>
): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = (): void => {
clearTimeout(timer)
child.removeListener('close', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
child.once('close', finish)
})
}
async function terminatePackageManager(
child: ReturnType<typeof spawn>
): Promise<void> {
const closed = waitForProcessClose(child)
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
await new Promise<void>((resolve) => {
const finish = (): void => {
clearTimeout(timer)
killer.removeListener('close', finish)
killer.removeListener('error', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
killer.once('close', finish)
killer.once('error', finish)
})
} else if (child.pid) {
try {
process.kill(-child.pid, 'SIGKILL')
} catch {
child.kill('SIGKILL')
}
} else {
child.kill('SIGKILL')
}
if (child.exitCode === null) {
child.kill('SIGKILL')
}
await closed
await terminateProcessTreeAndWait(child, {
processGroup: true,
signal: 'SIGKILL',
waitMs: 5_000
})
}
function boundedAppend(current: string, chunk: unknown): string {
+436 -93
View File
@@ -1351,19 +1351,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.any(Array),
expect.any(AbortSignal)
)
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
expect(JSON.stringify(
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
.mock.calls
@@ -1500,21 +1503,24 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
}
const questionEvent = await stream.next()
expect(questionEvent.value).toMatchObject({
type: 'question',
questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u),
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
})
await runtime.respondToQuestion('question-1', [['先写测试']])
const questionId =
questionEvent.value?.type === 'question'
? questionEvent.value.questionId
: ''
await runtime.respondToQuestion(questionId, [['先写测试']])
expect(setup.questionReply).toHaveBeenCalledWith({
requestID: 'question-1',
directory: process.cwd(),
@@ -1526,6 +1532,318 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('namespaces identical upstream question IDs across concurrent external conversations', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(async () => ({
stream: (async function* () {
yield {
id: 'question-event',
type: 'question.asked',
properties: {
id: 'shared-question',
sessionID: 'session-1',
questions: [
{
header: 'Choice',
question: 'Choose',
options: [],
multiple: false,
custom: true
}
]
}
}
yield {
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
}))
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'first',
workMode: 'execute'
},
new AbortController().signal
)
const second = runtime.run(
{
requestId: '4f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-2',
prompt: 'second',
workMode: 'execute'
},
new AbortController().signal
)
await Promise.all([first.next(), second.next()])
const [firstQuestion, secondQuestion] = await Promise.all([
first.next(),
second.next()
])
const firstId =
firstQuestion.value?.type === 'question'
? firstQuestion.value.questionId
: ''
const secondId =
secondQuestion.value?.type === 'question'
? secondQuestion.value.questionId
: ''
expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(firstId).not.toBe(secondId)
await Promise.all([
runtime.respondToQuestion(firstId, [['first answer']]),
runtime.respondToQuestion(secondId, [['second answer']])
])
expect(setup.questionReply).toHaveBeenCalledTimes(2)
expect(setup.questionReply).toHaveBeenNthCalledWith(1, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['first answer']]
})
expect(setup.questionReply).toHaveBeenNthCalledWith(2, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['second answer']]
})
await Promise.all([first.next(), second.next()])
await runtime.dispose()
})
it('aborts an OpenCode run at its total execution deadline', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(
async (
_input: unknown,
options: { signal: AbortSignal }
) => ({
stream: (async function* () {
await new Promise<void>((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(options.signal.reason),
{ once: true }
)
})
yield {
id: 'unreachable',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never finish',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('applies the total deadline while agent discovery is stalled', async () => {
const setup = runClient([])
const agents = vi.fn(
() => new Promise<never>(() => undefined)
)
Object.assign(setup.client, {
app: { agents }
})
const child = fakeChild()
const runtime = new OpenCodeRuntime(
options({
customization: { defaultAgent: 'build' }
}),
dependencies(child, {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
setTimeout(() => {
stdoutOf(child).write(
'opencode server listening on http://127.0.0.1:4010\n'
)
}, 0)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never reach the stream',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(agents).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(setup.event.subscribe).not.toHaveBeenCalled()
await runtime.dispose()
})
it('deletes a session created after timeout and does not reuse it', async () => {
const setup = runClient([
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
let resolveStalledCreation!: (value: {
data: { id: string }
error: undefined
}) => void
;(
setup.session.create as unknown as ReturnType<typeof vi.fn>
).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStalledCreation = resolve
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'stalled creation',
workMode: 'execute'
},
new AbortController().signal
)
await expect(first.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
resolveStalledCreation({
data: { id: 'stale-session' },
error: undefined
})
await vi.waitFor(() =>
expect(setup.session.delete).toHaveBeenCalledWith(
{
sessionID: 'stale-session',
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
)
const secondEvents = await collectRun(runtime)
expect(secondEvents.at(-1)).toMatchObject({ type: 'done' })
expect(setup.session.create).toHaveBeenCalledTimes(2)
expect(setup.session.update).not.toHaveBeenCalled()
await runtime.dispose()
})
it('fails before emitting text that exceeds the aggregate output budget', async () => {
const setup = runClient([
{
id: 'first-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'a'.repeat(600_000)
}
},
{
id: 'second-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'b'.repeat(600_000)
}
}
])
const runtime = embeddedRuntime(setup.client)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
await stream.next()
const firstText = await stream.next()
expect(firstText.value).toMatchObject({
type: 'text',
delta: expect.stringMatching(/^a+$/u)
})
await expect(stream.next()).rejects.toThrow(
'文本与推理输出超过 1 MB 安全限制'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => {
const setup = runClient([
{
@@ -1592,19 +1910,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
events.push(event)
}
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
const knowledgeMcpName = (
(
setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>
@@ -1621,7 +1942,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
action: 'allow'
}
]
})
}),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1634,10 +1956,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
}),
expect.anything()
)
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
})
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
{
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
@@ -1911,19 +2236,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
try {
await collectRun(runtime, 'ask')
expect(setup.session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
})
expect(setup.session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
},
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
system: undefined,
@@ -2001,13 +2329,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
const events = await collectRun(runtime, 'execute')
expect(callOrder).toEqual(['subscribe', 'prompt'])
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
@@ -2341,16 +2672,20 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'ask')
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(tool.ids).toHaveBeenCalledWith({
directory: process.cwd()
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(tool.ids).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
@@ -2378,13 +2713,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
await collectRun(runtime, 'ask')
expect(session.update).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(session.update).toHaveBeenCalledWith(
{
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
@@ -2411,13 +2749,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
expect(runtime.requiresToolApproval).toBe(false)
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).not.toHaveBeenCalled()
await runtime.dispose()
})
@@ -2814,7 +3155,8 @@ describe('OpenCodeRuntime native customization', () => {
}
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' })
expect.objectContaining({ agent: 'plan' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' }),
@@ -2853,7 +3195,8 @@ describe('OpenCodeRuntime native customization', () => {
await collectRun(runtime)
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' })
expect.objectContaining({ agent: 'build' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' }),
@@ -3099,7 +3442,7 @@ describe('OpenCodeRuntime native customization', () => {
})
expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' },
{ signal }
{ signal: expect.any(AbortSignal) }
)
expect(summarize).toHaveBeenCalledWith(
{
@@ -3109,7 +3452,7 @@ describe('OpenCodeRuntime native customization', () => {
modelID: 'claude-sonnet',
auto: false
},
{ signal }
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
+291 -114
View File
@@ -56,6 +56,10 @@ import type {
RuntimeSkillPackage
} from '../capabilities/capability-service'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000
@@ -65,6 +69,8 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = 100
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
const MAX_EXECUTION_MILLISECONDS = 10 * 60_000
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4
const MAX_QUESTION_OPTIONS = 20
@@ -243,6 +249,44 @@ function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum
}
function createPublicQuestionId(
requestId: string,
sessionId: string,
upstreamQuestionId: string
): string {
return `opencode-${createHash('sha256')
.update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`)
.digest('hex')
.slice(0, 48)}`
}
function executionDeadlineLabel(milliseconds: number): string {
return milliseconds % 60_000 === 0
? `${milliseconds / 60_000} 分钟`
: `${milliseconds} 毫秒`
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((resolveOperation, rejectOperation) => {
const abort = (): void => rejectOperation(signal.reason)
signal.addEventListener('abort', abort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', abort)
resolveOperation(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
rejectOperation(error)
}
)
})
}
function areBoundedPatterns(value: unknown): value is string[] {
return (
Array.isArray(value) &&
@@ -416,6 +460,7 @@ export type OpenCodeRuntimeDependencies = {
createClient: typeof createOpencodeClient
platform: NodeJS.Platform
startupTimeoutMs: number
executionTimeoutMs: number
}
export type OpenCodeRuntimeOptions = {
@@ -678,6 +723,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient
directory: string
questionCount: number
upstreamQuestionId: string
}
>()
private embeddedRunTail: Promise<void> = Promise.resolve()
@@ -694,6 +740,7 @@ export class OpenCodeRuntime implements AgentRuntime {
createClient: createOpencodeClient,
platform: process.platform,
startupTimeoutMs: STARTUP_TIMEOUT_MS,
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
...dependencies
}
}
@@ -782,36 +829,14 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private terminate(child: SpawnedProcess): void {
if (child.exitCode !== null) {
return
}
if (this.dependencies.platform === 'win32' && child.pid) {
const killer = this.dependencies.spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
requestProcessTreeTermination(child, {
platform: this.dependencies.platform,
spawn: this.dependencies.spawn
})
}
private waitForExit(child: SpawnedProcess): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => {
const timeout = setTimeout(resolveExit, 2_000)
child.once('close', () => {
clearTimeout(timeout)
resolveExit()
})
})
return waitForProcessExit(child)
}
private getNativeSkillIds(): string[] {
@@ -1083,9 +1108,12 @@ export class OpenCodeRuntime implements AgentRuntime {
if (this.client) {
return this.client
}
const existingInitialization = this.clientInitialization
this.clientInitialization ??= this.initializeClient(signal)
try {
return await this.clientInitialization
return signal && existingInitialization
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization
} catch (error) {
this.clientInitialization = undefined
throw error
@@ -1154,7 +1182,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private async discoverAgents(
client: OpencodeClient
client: OpencodeClient,
signal?: AbortSignal
): Promise<
Array<{
id: string
@@ -1165,9 +1194,15 @@ export class OpenCodeRuntime implements AgentRuntime {
hidden: boolean
}>
> {
const response = await client.app.agents({
directory: this.options.defaultWorkspace
})
const operation = client.app.agents(
{
directory: this.options.defaultWorkspace
},
signal ? { signal } : undefined
)
const response = signal
? await awaitWithAbort(operation, signal)
: await operation
if (response.error || !response.data) {
throw new Error('OpenCode Agent 清单不可用')
}
@@ -1197,7 +1232,8 @@ export class OpenCodeRuntime implements AgentRuntime {
private async resolveSelectedAgent(
client: OpencodeClient,
request: AgentExecutionRequest
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<string | undefined> {
const control =
request.runtimeControl?.provider === 'opencode'
@@ -1213,7 +1249,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent'
)
}
const agents = await this.discoverAgents(client)
const agents = await this.discoverAgents(client, signal)
if (
!agents.some(
(agent) =>
@@ -1592,6 +1628,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient,
request: AgentExecutionRequest,
directory: string,
signal: AbortSignal,
agent?: string,
permission?: PermissionRuleset
): Promise<{ id: string; created: boolean }> {
@@ -1603,27 +1640,62 @@ export class OpenCodeRuntime implements AgentRuntime {
request.conversationId
)
if (pending) {
return { id: await pending, created: false }
return {
id: await awaitWithAbort(pending, signal),
created: false
}
}
const creation = client.session
.create({
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
})
const creation: Promise<string> = client.session
.create(
{
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
},
{ signal }
)
.then((response) => {
if (!response.data) {
throw new Error('OpenCode 会话创建失败')
}
this.sessions.set(request.conversationId, response.data.id)
return response.data.id
const sessionId = response.data.id
const stillCurrent =
this.sessionInitializations.get(request.conversationId) ===
creation
if (signal.aborted || !stillCurrent) {
if (this.sessions.get(request.conversationId) !== sessionId) {
void client.session
.delete(
{
sessionID: sessionId,
directory
},
{ signal: AbortSignal.timeout(1_000) }
)
.catch(() => undefined)
}
if (signal.aborted) {
throw signal.reason
}
throw new Error('OpenCode 会话初始化已失效')
}
this.sessions.set(request.conversationId, sessionId)
return sessionId
})
this.sessionInitializations.set(request.conversationId, creation)
try {
return { id: await creation, created: true }
return {
id: await awaitWithAbort(creation, signal),
created: true
}
} finally {
this.sessionInitializations.delete(request.conversationId)
if (
this.sessionInitializations.get(request.conversationId) ===
creation
) {
this.sessionInitializations.delete(request.conversationId)
}
}
}
@@ -1631,17 +1703,42 @@ export class OpenCodeRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
const releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(signal)
: undefined
const releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(
new Error(
`OpenCode 执行超过 ${executionDeadlineLabel(
this.dependencies.executionTimeoutMs
)}总时限`
)
),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
yield* this.runUnlocked(request, signal)
releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(executionSignal)
: undefined
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal
)
yield* this.runUnlocked(request, executionSignal)
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
releaseConversation()
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded?.()
}
}
@@ -1674,7 +1771,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const selectedAgent = await this.resolveSelectedAgent(
client,
request
request,
signal
)
let selectedCommand:
| {
@@ -1683,7 +1781,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
| undefined
if (runtimeControl?.command) {
const commandResponse = await client.command.list({ directory })
const commandResponse = await awaitWithAbort(
client.command.list({ directory }, { signal }),
signal
)
if (commandResponse.error || !commandResponse.data) {
throw new Error('OpenCode 无法验证原生命令')
}
@@ -1725,19 +1826,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
if (added.error || !added.data) {
throw new Error('OpenCode 内置只读工具连接失败')
}
@@ -1776,19 +1883,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
const addedStatus = added.data?.[customMcpName]
if (
added.error ||
@@ -1834,9 +1947,10 @@ export class OpenCodeRuntime implements AgentRuntime {
]
let disabledTools: Record<string, boolean> | undefined
if (request.workMode !== 'execute') {
const tools = await client.tool.ids({
directory
})
const tools = await awaitWithAbort(
client.tool.ids({ directory }, { signal }),
signal
)
if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
}
@@ -1854,16 +1968,23 @@ export class OpenCodeRuntime implements AgentRuntime {
client,
request,
directory,
signal,
selectedAgent,
permission
)
const sessionId = session.id
if (!session.created) {
const update = await client.session.update({
sessionID: sessionId,
directory,
permission
})
const update = await awaitWithAbort(
client.session.update(
{
sessionID: sessionId,
directory,
permission
},
{ signal }
),
signal
)
if (update.error || !update.data) {
throw new Error('OpenCode 会话权限配置失败')
}
@@ -1875,9 +1996,10 @@ export class OpenCodeRuntime implements AgentRuntime {
message: 'OpenCode 正在处理请求'
}
const subscription = await client.event.subscribe({
directory
}, { signal })
const subscription = await awaitWithAbort(
client.event.subscribe({ directory }, { signal }),
signal
)
const abortSession = (): void => {
void client.session.abort({
@@ -1898,7 +2020,16 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>()
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
const consumeOutputBudget = (delta: string): void => {
aggregateOutputBytes += Buffer.byteLength(delta)
if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) {
throw new Error(
'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制'
)
}
}
let hasResponseTextAfterFailure = false
try {
const promptText =
@@ -1998,6 +2129,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking'
].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') {
consumeOutputBudget(event.properties.delta)
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
@@ -2077,6 +2209,7 @@ export class OpenCodeRuntime implements AgentRuntime {
event.properties.sessionID === sessionId &&
event.properties.delta
) {
consumeOutputBudget(event.properties.delta)
yield {
requestId: request.requestId,
type: 'reasoning',
@@ -2096,16 +2229,28 @@ export class OpenCodeRuntime implements AgentRuntime {
questionRequest &&
!reportedQuestionIds.has(questionRequest.id)
) {
reportedQuestionIds.add(questionRequest.id)
this.pendingQuestions.set(questionRequest.id, {
const publicQuestionId = createPublicQuestionId(
request.requestId,
sessionId,
questionRequest.id
)
if (this.pendingQuestions.has(publicQuestionId)) {
throw new Error('OpenCode 提问公开 ID 与另一活动请求冲突')
}
reportedQuestionIds.set(
questionRequest.id,
publicQuestionId
)
this.pendingQuestions.set(publicQuestionId, {
client,
directory,
questionCount: questionRequest.questions.length
questionCount: questionRequest.questions.length,
upstreamQuestionId: questionRequest.id
})
yield {
requestId: request.requestId,
type: 'question',
questionId: questionRequest.id,
questionId: publicQuestionId,
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
@@ -2125,7 +2270,12 @@ export class OpenCodeRuntime implements AgentRuntime {
event.type === 'question.rejected') &&
event.properties.sessionID === sessionId
) {
this.pendingQuestions.delete(event.properties.requestID)
const publicQuestionId = reportedQuestionIds.get(
event.properties.requestID
)
if (publicQuestionId) {
this.pendingQuestions.delete(publicQuestionId)
}
}
if (
@@ -2320,20 +2470,29 @@ export class OpenCodeRuntime implements AgentRuntime {
throw error
} finally {
signal.removeEventListener('abort', abortSession)
for (const questionId of reportedQuestionIds) {
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId)
}
}
} finally {
const cleanupSignal = AbortSignal.timeout(1_000)
if (knowledgeMcpName) {
await client.mcp
.disconnect({ name: knowledgeMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: knowledgeMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpName) {
await client.mcp
.disconnect({ name: customMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: customMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken)
@@ -2352,13 +2511,13 @@ export class OpenCodeRuntime implements AgentRuntime {
const response = answers
? answers.length === pending.questionCount
? await pending.client.question.reply({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory,
answers
})
: undefined
: await pending.client.question.reject({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory
})
if (!response) {
@@ -2382,12 +2541,24 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact'
)
}
const releaseEmbedded = await this.acquireEmbeddedRun(signal)
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(new Error('OpenCode 原生 Compact 执行超时')),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
executionSignal
)
const sessionId = this.sessions.get(request.conversationId)
if (!sessionId) {
@@ -2400,10 +2571,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
const client = await this.getClient(signal)
const client = await this.getClient(executionSignal)
const context = await client.v2.session.context(
{ sessionID: sessionId },
{ signal }
{ signal: executionSignal }
)
if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
@@ -2434,13 +2605,13 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
signal.throwIfAborted()
executionSignal.throwIfAborted()
const subscriptionController = new AbortController()
const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{
signal: AbortSignal.any([
signal,
executionSignal,
subscriptionController.signal
])
}
@@ -2482,7 +2653,7 @@ export class OpenCodeRuntime implements AgentRuntime {
modelID: configuredModel.modelID,
auto: false
},
{ signal }
{ signal: executionSignal }
)
if (compact.error || compact.data !== true) {
throw new Error(
@@ -2522,9 +2693,15 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort()
await usageCapture.catch(() => undefined)
}
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded()
releaseEmbedded?.()
}
}
+53 -2
View File
@@ -1,10 +1,13 @@
import { EventEmitter } from 'node:events'
import { realpath } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectAgentRuntimes,
detectRuntimeBinary
detectRuntimeBinary,
validateRuntimeVersion,
type RuntimeVersionProcess
} from './runtime-discovery'
const originalPath = process.env.PATH
@@ -24,6 +27,54 @@ afterEach(() => {
})
describe('runtime discovery', () => {
it('launches a detached POSIX version probe and awaits bounded tree cleanup', async () => {
const child =
new EventEmitter() as RuntimeVersionProcess & EventEmitter
child.exitCode = null
child.pid = 2718
child.kill = vi.fn()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
const spawnProcess = vi.fn(() => child)
let releaseCleanup!: () => void
const cleanupBlocked = new Promise<void>((resolve) => {
releaseCleanup = resolve
})
const terminateProcessTree = vi.fn(async () => {
await cleanupBlocked
})
const validation = validateRuntimeVersion('runtime', {
platform: 'linux',
spawnProcess,
terminateProcessTree,
outputLimit: 4,
terminationWaitMs: 25
})
;(child.stdout as EventEmitter).emit('data', '12345')
let completed = false
void validation.then(() => {
completed = true
})
await Promise.resolve()
expect(completed).toBe(false)
expect(spawnProcess).toHaveBeenCalledWith(
'runtime',
['--version'],
expect.objectContaining({ detached: true })
)
expect(terminateProcessTree).toHaveBeenCalledWith(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
waitMs: 25
})
releaseCleanup()
await expect(validation).resolves.toEqual({ valid: false })
})
it('canonicalizes and validates a configured ordinary file first', async () => {
process.env.PATH = ''
process.env.Path = ''
+103 -44
View File
@@ -8,6 +8,10 @@ import {
normalize
} from 'node:path'
import spawn from 'cross-spawn'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildRuntimeEnvironment } from './process-environment'
import type {
AgentRuntimeDetection,
@@ -16,6 +20,7 @@ import type {
const VERSION_TIMEOUT_MS = 3_000
const VERSION_OUTPUT_LIMIT = 8 * 1024
const VERSION_TERMINATION_WAIT_MS = 500
export type RuntimeBinaryDiscoveryInput = {
binaryPath: string
@@ -31,6 +36,39 @@ type VersionValidation =
| { valid: true; version?: string }
| { valid: false }
type RuntimeVersionOutput = {
on(
event: 'data',
listener: (chunk: Buffer | string) => void
): unknown
}
export type RuntimeVersionProcess = WaitableProcessTreeChild & {
stdout?: RuntimeVersionOutput | null
stderr?: RuntimeVersionOutput | null
}
export type RuntimeVersionSpawn = (
command: string,
args: string[],
options: {
detached: boolean
env: NodeJS.ProcessEnv
shell: false
stdio: ['ignore', 'pipe', 'pipe']
windowsHide: true
}
) => RuntimeVersionProcess
export type RuntimeVersionValidationDependencies = {
platform?: NodeJS.Platform
spawnProcess?: RuntimeVersionSpawn
terminateProcessTree?: typeof terminateProcessTreeAndWait
timeoutMs?: number
outputLimit?: number
terminationWaitMs?: number
}
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
@@ -66,45 +104,36 @@ function safeVersion(output: string): string | undefined {
return (semanticVersion?.[1] ?? firstLine).slice(0, 160)
}
function terminate(child: ReturnType<typeof spawn>): void {
if (child.exitCode !== null || child.killed) {
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill('SIGKILL')
}
function validateVersion(binaryPath: string): Promise<VersionValidation> {
export function validateRuntimeVersion(
binaryPath: string,
dependencies: RuntimeVersionValidationDependencies = {}
): Promise<VersionValidation> {
return new Promise((resolve) => {
let settled = false
let cleanupStarted = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const platform = dependencies.platform ?? process.platform
const timeoutMs = dependencies.timeoutMs ?? VERSION_TIMEOUT_MS
const outputLimit =
dependencies.outputLimit ?? VERSION_OUTPUT_LIMIT
const child = spawn(binaryPath, ['--version'], {
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
const child = (dependencies.spawnProcess ?? spawn)(
binaryPath,
['--version'],
{
detached: platform !== 'win32',
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const finish = (result: VersionValidation): void => {
if (settled) {
if (settled || cleanupStarted) {
return
}
settled = true
@@ -112,21 +141,43 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
resolve(result)
}
const exceedLimit = (): void => {
terminate(child)
finish({ valid: false })
const failAfterCleanup = (): void => {
if (settled || cleanupStarted) {
return
}
cleanupStarted = true
clearTimeout(timeout)
void (
dependencies.terminateProcessTree ??
terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs:
dependencies.terminationWaitMs ??
VERSION_TERMINATION_WAIT_MS
}).then(
() => {
settled = true
resolve({ valid: false })
},
() => {
settled = true
resolve({ valid: false })
}
)
}
const timeout = setTimeout(() => {
terminate(child)
finish({ valid: false })
}, VERSION_TIMEOUT_MS)
failAfterCleanup()
}, timeoutMs)
child.stdout?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stdoutBytes += value.byteLength
if (stdoutBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stdoutBytes > outputLimit) {
failAfterCleanup()
return
}
stdout += value.toString('utf8')
@@ -134,14 +185,22 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
child.stderr?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stderrBytes += value.byteLength
if (stderrBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stderrBytes > outputLimit) {
failAfterCleanup()
return
}
stderr += value.toString('utf8')
})
child.once('error', () => finish({ valid: false }))
child.once('error', () => {
if (cleanupStarted) {
return
}
finish({ valid: false })
})
child.once('close', (code) => {
if (cleanupStarted) {
return
}
if (code !== 0) {
finish({ valid: false })
return
@@ -286,7 +345,7 @@ export async function detectRuntimeBinary(
'bundled'
)
}
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
return validation.valid
? availableDetection(
input.label,
@@ -305,7 +364,7 @@ export async function detectRuntimeBinary(
if (!canonicalPath) {
configuredPathProblem = 'invalid'
} else {
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -332,7 +391,7 @@ export async function detectRuntimeBinary(
continue
}
foundAutomaticCandidate = true
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -3,7 +3,9 @@ import {
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -302,6 +304,177 @@ describe('RuntimeExtensionStore', () => {
})
})
it('rolls back an interrupted upgrade whose state was not committed', async () => {
const first = catalogEntry('1.0.0')
const second = catalogEntry('2.0.0')
const { userDataPath, store, dependencies } = await fixture({
entries: [first],
temporaryIds: ['install-one']
})
await store.apply({
type: 'install',
extensionId: first.id,
package: first.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = structuredClone(before)
after.installed[0] = {
...after.installed[0],
package: second.package,
installedAt: '2026-08-17T00:00:00.000Z',
integrity: `sha512-${Buffer.from('new').toString('base64')}`
}
const extensionDirectory = join(
root,
'extensions',
first.id
)
const backupDirectory = join(
root,
'.staging',
'upgrade-crash-previous'
)
await rename(extensionDirectory, backupDirectory)
await mkdir(join(extensionDirectory, 'dist'), { recursive: true })
await writeFile(
join(extensionDirectory, 'dist', 'index.js'),
'export default "new"'
)
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'install',
extensionId: first.id,
temporaryId: 'upgrade-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: [
expect.objectContaining({ package: first.package })
]
})
await expect(
readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8')
).resolves.toBe('export default {}')
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('finishes an interrupted committed removal on initialization', async () => {
const { userDataPath, store, dependencies } = await fixture({
temporaryIds: ['install-one']
})
const entry = catalogEntry()
await store.apply({
type: 'install',
extensionId: entry.id,
package: entry.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = { ...before, installed: [] }
const trashDirectory = join(
root,
'.staging',
'remove-crash-removed'
)
await rename(
join(root, 'extensions', entry.id),
trashDirectory
)
await writeFile(statePath, JSON.stringify(after))
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'remove',
extensionId: entry.id,
temporaryId: 'remove-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: []
})
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(join(root, 'extensions'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('cleans only safe unjournaled managed staging directories', async () => {
const { userDataPath, dependencies } = await fixture({
marketplaceEnabled: false
})
const root = join(userDataPath, 'runtime-extensions')
const staging = join(root, '.staging')
const abandoned =
'00000000-0000-4000-8000-000000000101'
const abandonedBackup =
'00000000-0000-4000-8000-000000000102-previous'
const unrelated = 'user-staging-backup'
const outside = join(userDataPath, 'outside-staging')
const linked =
'00000000-0000-4000-8000-000000000103-removed'
await mkdir(staging, { recursive: true })
await Promise.all([
mkdir(join(staging, abandoned)),
mkdir(join(staging, abandonedBackup)),
mkdir(join(staging, unrelated)),
mkdir(outside)
])
await writeFile(join(staging, abandoned, 'partial.js'), 'stale')
await writeFile(join(staging, unrelated, 'keep.txt'), 'keep')
await writeFile(join(outside, 'keep.txt'), 'outside')
await symlink(outside, join(staging, linked), 'junction')
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await recovered.getSnapshot()
expect(await readdir(staging)).toEqual(
expect.arrayContaining([unrelated, linked])
)
expect(await readdir(staging)).not.toContain(abandoned)
expect(await readdir(staging)).not.toContain(abandonedBackup)
await expect(
readFile(join(staging, unrelated, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(join(outside, 'keep.txt'), 'utf8')
).resolves.toBe('outside')
})
it('rejects installer entrypoints outside the managed package', async () => {
const entry = catalogEntry()
const fixtureValue = await fixture({
+239 -33
View File
@@ -33,6 +33,9 @@ import {
const managedDirectoryName = 'runtime-extensions'
const stateFileName = 'store.json'
const journalFileName = '.mutation-journal.json'
const unjournaledStagingDirectoryPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:-previous|-removed)?$/iu
const version1StoredStateSchema = z
.object({
@@ -56,6 +59,19 @@ const storedStateFileSchema = z.union([
type StoredState = z.infer<typeof storedStateSchema>
const mutationJournalSchema = z
.object({
version: z.literal(1),
kind: z.enum(['install', 'remove']),
extensionId: runtimeExtensionIdSchema,
temporaryId: runtimeExtensionIdSchema,
before: storedStateSchema,
after: storedStateSchema
})
.strict()
type MutationJournal = z.infer<typeof mutationJournalSchema>
export interface RuntimeExtensionCatalog {
list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
}
@@ -128,6 +144,7 @@ export class RuntimeExtensionStore {
readonly managedRoot: string
private readonly statePath: string
private readonly journalPath: string
private state?: StoredState
private stateLoad?: Promise<StoredState>
private canonicalRoot?: string
@@ -144,6 +161,7 @@ export class RuntimeExtensionStore {
}
this.managedRoot = resolve(userDataPath, managedDirectoryName)
this.statePath = join(this.managedRoot, stateFileName)
this.journalPath = join(this.managedRoot, journalFileName)
}
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
@@ -176,6 +194,7 @@ export class RuntimeExtensionStore {
): Promise<RuntimeExtensionApplyResult> {
const parsed = runtimeExtensionActionSchema.parse(action)
const changed = await this.serialize(async () => {
await this.reconcileMutationJournal()
switch (parsed.type) {
case 'set-marketplace-enabled':
return this.setMarketplaceEnabled(parsed.enabled)
@@ -306,6 +325,8 @@ export class RuntimeExtensionStore {
this.canonicalRoot = await realpath(this.managedRoot)
await this.createManagedDirectory('extensions')
await this.createManagedDirectory('.staging')
await this.reconcileMutationJournal()
await this.cleanupUnjournaledStagingDirectories()
}
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
@@ -354,9 +375,12 @@ export class RuntimeExtensionStore {
`${temporaryId}-previous`
)
const finalDirectory = this.extensionDirectory(extensionId)
let previousMoved = false
let stagedMoved = false
try {
if (await this.pathExists(backupDirectory)) {
throw new Error(
'Extension upgrade backup path already exists'
)
}
const installedPackage = await this.dependencies.install({
entry,
destinationDirectory: stagedDirectory
@@ -366,12 +390,6 @@ export class RuntimeExtensionStore {
installedPackage.entrypoint
)
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
previousMoved = true
}
await rename(stagedDirectory, finalDirectory)
stagedMoved = true
const entrypoint = resolve(
finalDirectory,
installedPackage.entrypoint
@@ -392,19 +410,29 @@ export class RuntimeExtensionStore {
? { integrity: installedPackage.integrity }
: {})
}
await this.persistAndSet(
this.replaceInstalled(state, installed)
)
if (previousMoved) {
await this.removeManagedTree(backupDirectory).catch(() => undefined)
const nextState = this.replaceInstalled(state, installed)
const journal: MutationJournal = {
version: 1,
kind: 'install',
extensionId,
temporaryId,
before: state,
after: nextState
}
await this.writeMutationJournal(journal)
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
}
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
}
await rename(stagedDirectory, finalDirectory)
await this.persistAndSet(nextState)
await this.removeManagedTree(backupDirectory)
await this.clearMutationJournal()
} catch (error) {
if (stagedMoved) {
await this.removeManagedTree(finalDirectory)
}
if (previousMoved) {
await rename(backupDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
} finally {
await this.removeManagedTree(stagedDirectory).catch(() => undefined)
@@ -488,27 +516,40 @@ export class RuntimeExtensionStore {
'.staging',
`${randomUUID()}-removed`
)
let moved = false
const temporaryId = trashDirectory
.slice(trashDirectory.lastIndexOf(sep) + 1)
.replace(/-removed$/u, '')
runtimeExtensionIdSchema.parse(temporaryId)
if (await this.pathExists(trashDirectory)) {
throw new Error('Extension removal staging path already exists')
}
const nextState: StoredState = {
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
}
await this.writeMutationJournal({
version: 1,
kind: 'remove',
extensionId,
temporaryId,
before: state,
after: nextState
})
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
await rename(finalDirectory, trashDirectory)
moved = true
}
try {
await this.persistAndSet({
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
})
await this.persistAndSet(nextState)
} catch (error) {
if (moved) {
await rename(trashDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
}
if (moved) {
await this.removeManagedTree(trashDirectory).catch(() => undefined)
}
await this.removeManagedTree(trashDirectory)
await this.clearMutationJournal()
}
private requireInstalled(
@@ -549,6 +590,171 @@ export class RuntimeExtensionStore {
)
}
private writeMutationJournal(journal: MutationJournal): Promise<void> {
return writeJsonFileAtomically(
this.journalPath,
mutationJournalSchema.parse(journal)
)
}
private async clearMutationJournal(): Promise<void> {
await unlink(this.journalPath).catch((error: unknown) => {
if (!isMissingFileError(error)) {
throw error
}
})
}
private async reconcileMutationJournal(): Promise<void> {
let journal: MutationJournal
try {
const status = await lstat(this.journalPath)
if (
!status.isFile() ||
status.isSymbolicLink() ||
status.nlink > 1
) {
throw new Error(
'Extension mutation journal must be a regular file'
)
}
await this.assertExistingPathContained(this.journalPath)
journal = mutationJournalSchema.parse(
JSON.parse(await readFile(this.journalPath, 'utf8')) as unknown
)
} catch (error) {
if (isMissingFileError(error)) {
return
}
throw error
}
const stateStatus = await lstat(this.statePath)
if (
!stateStatus.isFile() ||
stateStatus.isSymbolicLink() ||
stateStatus.nlink > 1
) {
throw new Error('Extension store state must be a regular file')
}
await this.assertExistingPathContained(this.statePath)
const stored = storedStateFileSchema.parse(
JSON.parse(await readFile(this.statePath, 'utf8')) as unknown
)
if (stored.version !== 2) {
throw new Error(
'Extension mutation journal requires current store state'
)
}
const committed = isDeepStrictEqual(stored, journal.after)
const rolledBack = isDeepStrictEqual(stored, journal.before)
if (!committed && !rolledBack) {
throw new Error(
'Extension mutation journal does not match store state'
)
}
const finalDirectory = this.extensionDirectory(journal.extensionId)
const stagedDirectory = this.managedPath(
'.staging',
journal.temporaryId
)
const auxiliaryDirectory = this.managedPath(
'.staging',
journal.kind === 'install'
? `${journal.temporaryId}-previous`
: `${journal.temporaryId}-removed`
)
if (journal.kind === 'install') {
if (committed) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.after, journal.extensionId)
)
await this.removeManagedTree(auxiliaryDirectory)
await this.removeManagedTree(stagedDirectory)
} else {
const hadPrevious = journal.before.installed.some(
(extension) => extension.id === journal.extensionId
)
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
} else if (!hadPrevious) {
await this.removeManagedTree(finalDirectory)
}
if (hadPrevious) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(
journal.before,
journal.extensionId
)
)
}
await this.removeManagedTree(stagedDirectory)
}
} else if (committed) {
await this.removeManagedTree(finalDirectory)
await this.removeManagedTree(auxiliaryDirectory)
} else {
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
}
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.before, journal.extensionId)
)
}
await this.clearMutationJournal()
this.state = stored
}
private async assertRealManagedDirectory(path: string): Promise<void> {
this.assertContained(this.managedRoot, path)
const status = await lstat(path)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error(
'Extension package path must be a real directory'
)
}
await this.assertExistingPathContained(path)
}
private async cleanupUnjournaledStagingDirectories(): Promise<void> {
const stagingDirectory = this.managedPath('.staging')
await this.assertRealManagedDirectory(stagingDirectory)
const entries = await readdir(stagingDirectory, {
withFileTypes: true
})
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
!unjournaledStagingDirectoryPattern.test(entry.name)
) {
continue
}
const directory = this.managedPath('.staging', entry.name)
await this.assertRealManagedDirectory(directory)
await this.removeManagedTree(directory)
}
}
private async assertInstalledStateOnDisk(
extension: RuntimeExtensionInstalledState
): Promise<void> {
this.assertExtensionEntrypoint(extension)
const directory = this.extensionDirectory(extension.id)
await this.assertRealManagedDirectory(directory)
const relativeEntrypoint = relative(
directory,
extension.entrypoint
).split(sep).join('/')
await this.resolveEntrypoint(directory, relativeEntrypoint)
}
private assertExtensionEntrypoint(
extension: RuntimeExtensionInstalledState
): void {
+421 -6
View File
@@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import { AssistantDatabase } from './assistant-database'
const temporaryDirectories: string[] = []
@@ -156,7 +161,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 23', async () => {
it('migrates existing databases to schema version 25', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -185,7 +190,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(23)
).toBe(25)
expect(
current
.prepare(
@@ -200,7 +205,12 @@ describe('AssistantDatabase', () => {
.all()
).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'runtime_selection_json' })
expect.objectContaining({ name: 'runtime_selection_json' }),
expect.objectContaining({
name: 'built_in_default',
notnull: 1,
dflt_value: '0'
})
])
)
const foreignKeys = current
@@ -268,6 +278,174 @@ describe('AssistantDatabase', () => {
current.close()
})
it('backfills one exact legacy built-in default candidate', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-default-project-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const [project] = migrated.listProjects()
expect(project).toMatchObject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
builtInDefault: true
})
expect(
project && isUntouchedBuiltInDefaultProject(project)
).toBe(true)
migrated.close()
})
it('does not backfill an ambiguous legacy default identity', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-ambiguous-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const independent = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'D:\\Independent',
defaultWorkMode: 'ask'
})
expect(independent.builtInDefault).toBe(false)
expect(isUntouchedBuiltInDefaultProject(independent)).toBe(false)
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(
migrated
.listProjects()
.map((project) => project.builtInDefault)
).toEqual([false, false])
migrated.close()
})
it('does not mark a later exact clone after the original default was edited', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-edited-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const original = initial.listProjects()[0]!
initial.updateProject(original.id, {
name: '已编辑默认项目',
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
const clone = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(
migrated
.listProjects()
.filter((project) => project.builtInDefault)
).toEqual([])
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
migrated.close()
})
it('does not mark a later exact clone after the original default was deleted', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-deleted-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const original = initial.listProjects()[0]!
const clone = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
initial.deleteProject(original.id, original.name)
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
migrated.close()
})
it('does not backfill when no exact legacy candidate exists', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-missing-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
UPDATE projects
SET updated_at = created_at || '-edited';
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(migrated.listProjects()[0]?.builtInDefault).toBe(false)
migrated.close()
})
it('idempotently migrates version 5 databases to computer control audit schema', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-control-audit-migration-')
@@ -298,7 +476,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(23)
).toBe(25)
expect(
current
.prepare(
@@ -436,7 +614,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 23 })
).toEqual({ user_version: 25 })
expect(
inspected
.prepare(
@@ -566,11 +744,34 @@ describe('AssistantDatabase', () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
expect(defaultProject).toMatchObject({
name: '默认项目',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active'
})
expect(defaultProject?.runtimeSelection).toBeUndefined()
expect(defaultProject?.createdAt).toBe(defaultProject?.updatedAt)
expect(
defaultProject &&
isUntouchedBuiltInDefaultProject(defaultProject)
).toBe(true)
const reconfiguredDefault = database.updateProject(
defaultProject!.id,
{
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'D:\\Moved',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' }
}
)
expect(reconfiguredDefault.builtInDefault).toBe(true)
expect(
isUntouchedBuiltInDefaultProject(reconfiguredDefault)
).toBe(true)
expect(database.listExperts()).toHaveLength(3)
const project = database.createProject({
@@ -579,6 +780,7 @@ describe('AssistantDatabase', () => {
rootPath: 'C:\\Release',
defaultWorkMode: 'ask'
})
expect(project.builtInDefault).toBe(false)
expect(database.listProjects()).toHaveLength(2)
const updated = database.updateProject(project.id, {
@@ -863,6 +1065,192 @@ describe('AssistantDatabase', () => {
reopened.close()
})
it('keeps exhausted channel results terminal and observable', async () => {
const database = await createDatabase()
const entry = database.enqueueChannelResult({
channel: 'weixin',
eventId: 'terminal-event',
conversationId: 'conversation-1',
recipientId: 'sender-1',
status: 'completed',
output: '已完成',
attachments: [
{
name: 'result.txt',
mimeType: 'text/plain',
size: 1,
kind: 'file',
dataBase64: 'eA=='
}
]
})
for (let attempt = 0; attempt < 5; attempt += 1) {
database.markChannelResult(entry.id, 'failed')
}
const terminal = database.listUndeliveredChannelResults()
expect(terminal).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5,
message: expect.objectContaining({
eventId: 'terminal-event',
output: '已完成'
})
})
])
expect(terminal[0]?.message).not.toHaveProperty('attachments')
database.close()
})
it('migrates exhausted legacy outbox failures to terminal state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const entry = initial.enqueueChannelResult({
channel: 'weixin',
eventId: 'legacy-terminal-event',
conversationId: 'conversation-1',
recipientId: 'sender-1',
status: 'completed',
output: '已完成',
attachments: [
{
name: 'legacy.txt',
mimeType: 'text/plain',
size: 1,
kind: 'file',
dataBase64: 'eA=='
}
]
})
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy
.prepare(
`UPDATE channel_outbox
SET state = 'failed', attempts = 5
WHERE id = ?`
)
.run(entry.id)
legacy.exec('PRAGMA user_version = 23')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const terminal = migrated.listUndeliveredChannelResults()
expect(terminal).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5
})
])
expect(terminal[0]?.message).not.toHaveProperty('attachments')
migrated.close()
})
it('rolls back both heartbeat failure updates atomically', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-heartbeat-rollback-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const config = database.createHeartbeatConfig(
{
scope: { kind: 'global' },
name: '事务心跳',
timezone: 'UTC',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30
},
new Date('2026-08-16T00:00:00.000Z')
)
const claim = database.claimHeartbeatNow(
config.id,
'heartbeat-rollback',
'test-owner',
new Date('2026-08-16T01:00:00.000Z')
)
const raw = new DatabaseSync(databasePath)
raw.exec(`
CREATE TRIGGER reject_heartbeat_config_failure
BEFORE UPDATE OF last_status ON heartbeat_configs
BEGIN
SELECT RAISE(ABORT, 'forced config update failure');
END;
`)
raw.close()
expect(() =>
database.failHeartbeatRun(
claim,
'runtime failed',
new Date('2026-08-16T01:01:00.000Z')
)
).toThrow('forced config update failure')
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
status: 'claimed',
attemptCount: 1,
completedAt: undefined,
error: undefined
})
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
'claimed'
)
database.close()
})
it('rejects heartbeat failure after its lease expires', async () => {
const database = await createDatabase()
const config = database.createHeartbeatConfig(
{
scope: { kind: 'global' },
name: '租约过期心跳',
timezone: 'UTC',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30
},
new Date('2026-08-16T00:00:00.000Z')
)
const claim = database.claimHeartbeatNow(
config.id,
'expired-heartbeat',
'expired-owner',
new Date('2026-08-16T01:00:00.000Z'),
60_000
)
expect(() =>
database.failHeartbeatRun(
claim,
'late worker failure',
new Date('2026-08-16T01:01:00.001Z')
)
).toThrow('Heartbeat lease is no longer active')
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
status: 'claimed',
completedAt: undefined,
error: undefined
})
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
'claimed'
)
database.close()
})
it('preserves legacy channel event claims while adding account identity', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-event-migration-')
@@ -2299,6 +2687,7 @@ describe('AssistantDatabase', () => {
it('explicitly deletes only local conversations and cascades messages', async () => {
const database = await createDatabase()
const localId = '00000000-0000-4000-8000-000000000521'
const localTaskId = '00000000-0000-4000-8000-000000000523'
database.replaceConversations([
{
id: localId,
@@ -2341,6 +2730,28 @@ describe('AssistantDatabase', () => {
recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
database.createTask({
id: localTaskId,
conversationId: localId,
title: '本地对话任务',
instructions: '生成仅属于对话的回复',
workMode: 'ask'
})
database.updateTaskStatus(localTaskId, 'completed')
const hiddenReply = database.createTextArtifact({
taskId: localTaskId,
title: '本地对话回复',
content: '删除对话后不得进入成果列表'
})
database.saveDelegationResult(localTaskId, {
status: 'completed',
output: '不应残留'
})
expect(
database
.listArtifacts()
.some((artifact) => artifact.id === hiddenReply.id)
).toBe(false)
expect(database.deleteLocalConversation(localId)).toBe(true)
expect(database.deleteLocalConversation(localId)).toBe(false)
@@ -2357,6 +2768,10 @@ describe('AssistantDatabase', () => {
expect(() =>
database.getConversation(localId)
).toThrow('对话不存在')
expect(() => database.getArtifact(hiddenReply.id)).toThrow(
'成果不存在'
)
expect(database.listPendingDelegationResults()).toEqual([])
expect(() =>
database.deleteLocalConversation(remote.id)
).toThrow('远程对话不能作为本地对话删除')
+217 -41
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
conversationSnapshotSchema,
expertCreateSchema,
normalizeInteractiveWorkMode,
@@ -82,6 +84,7 @@ type ProjectRow = {
runtime_selection_json: string | null
kind: AssistantProject['kind']
channel: ProjectChannel | null
built_in_default: number
status: AssistantProject['status']
created_at: string
updated_at: string
@@ -427,6 +430,7 @@ function toProject(row: ProjectRow): AssistantProject {
: parseRuntimeSelection(row.runtime_selection_json),
kind: row.kind,
channel: row.channel ?? undefined,
builtInDefault: row.built_in_default === 1,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
@@ -982,12 +986,15 @@ export class AssistantDatabase {
.prepare('SELECT COUNT(*) AS count FROM projects')
.get() as { count: number }
if (count.count === 0) {
this.createProject({
name: '默认项目',
description: 'GoodBuddy 默认工作区',
rootPath: defaultRootPath,
defaultWorkMode: 'ask'
})
this.createLocalProject(
{
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: defaultRootPath,
defaultWorkMode: 'ask'
},
true
)
}
const expertCount = database
.prepare('SELECT COUNT(*) AS count FROM experts')
@@ -1283,6 +1290,13 @@ export class AssistantDatabase {
}
createProject(input: ProjectCreateInput): AssistantProject {
return this.createLocalProject(input, false)
}
private createLocalProject(
input: ProjectCreateInput,
builtInDefault: boolean
): AssistantProject {
const database = this.requireDatabase()
const id = randomUUID()
const now = new Date().toISOString()
@@ -1290,9 +1304,9 @@ export class AssistantDatabase {
.prepare(
`INSERT INTO projects
(id, name, description, root_path, default_work_mode,
runtime_selection_json, kind, channel, status, created_at,
updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)`
runtime_selection_json, kind, channel, built_in_default,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)`
)
.run(
id,
@@ -1303,6 +1317,7 @@ export class AssistantDatabase {
input.runtimeSelection
? JSON.stringify(input.runtimeSelection)
: null,
builtInDefault ? 1 : 0,
now,
now
)
@@ -1913,6 +1928,32 @@ export class AssistantDatabase {
)`
)
.run(conversationId)
database
.prepare(
`DELETE FROM delegation_outbox
WHERE task_id IN (
SELECT id FROM tasks WHERE conversation_id = ?
)`
)
.run(conversationId)
database
.prepare(
`DELETE FROM artifacts
WHERE kind = 'markdown'
AND task_id IN (
SELECT id
FROM tasks
WHERE conversation_id = ?
AND (
origin = 'user'
OR (
origin = 'delegation'
AND conversation_id NOT LIKE 'delegation:%'
)
)
)`
)
.run(conversationId)
database
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
.run(conversationId)
@@ -2214,7 +2255,11 @@ export class AssistantDatabase {
this.requireDatabase()
.prepare(
`UPDATE channel_outbox
SET state = ?,
SET state = CASE
WHEN ? = 'failed' AND attempts + 1 >= 5
THEN 'terminal'
ELSE ?
END,
attempts = attempts + 1,
message_json = CASE
WHEN ? = 'delivered' OR attempts + 1 >= 5
@@ -2223,7 +2268,7 @@ export class AssistantDatabase {
END
WHERE id = ?`
)
.run(state, state, id)
.run(state, state, state, id)
}
listUndeliveredChannelResults(
@@ -2232,7 +2277,7 @@ export class AssistantDatabase {
): Array<{
id: string
message: ChannelResultMessage
state: 'pending' | 'failed'
state: 'pending' | 'failed' | 'terminal'
attempts: number
createdAt: number
}> {
@@ -2252,7 +2297,6 @@ export class AssistantDatabase {
) AS cumulative_bytes
FROM channel_outbox
WHERE state != 'delivered'
AND attempts < 5
${channel === undefined ? '' : 'AND channel = ?'}
)
SELECT id, message_json, state, attempts, created_at
@@ -2272,7 +2316,7 @@ export class AssistantDatabase {
) as Array<{
id: string
message_json: string
state: 'pending' | 'failed'
state: 'pending' | 'failed' | 'terminal'
attempts: number
created_at: number
}>
@@ -5340,32 +5384,46 @@ export class AssistantDatabase {
]!
).toISOString()
: null
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = ?,
completed_at = ?, error = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND status = 'claimed' AND lease_owner = ?`
)
.run(
nextAttemptAt,
timestamp,
error.slice(0, 2_000),
timestamp,
claim.run.id,
claim.leaseOwner
)
if (result.changes !== 1) {
throw new Error('Heartbeat lease is no longer active')
database.exec('BEGIN IMMEDIATE')
try {
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = ?,
completed_at = ?, error = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND config_id = ?
AND status = 'claimed' AND lease_owner = ?
AND lease_expires_at > ?`
)
.run(
nextAttemptAt,
timestamp,
error.slice(0, 2_000),
timestamp,
claim.run.id,
claim.config.id,
claim.leaseOwner,
timestamp
)
if (result.changes !== 1) {
throw new Error('Heartbeat lease is no longer active')
}
const configResult = database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'failed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
if (configResult.changes !== 1) {
throw new Error('Heartbeat config no longer exists')
}
database.exec('COMMIT')
} catch (transactionError) {
database.exec('ROLLBACK')
throw transactionError
}
database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'failed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
return this.getHeartbeatRun(claim.run.id)
}
@@ -5741,12 +5799,12 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version > 23) {
if (version.user_version > 25) {
throw new Error(
` GoodBuddy ${version.user_version}`
)
}
if (version.user_version === 23) {
if (version.user_version === 25) {
return
}
if (version.user_version < 1) {
@@ -5760,6 +5818,8 @@ export class AssistantDatabase {
default_work_mode TEXT NOT NULL
CHECK(default_work_mode IN ('ask', 'execute')),
runtime_selection_json TEXT,
built_in_default INTEGER NOT NULL DEFAULT 0
CHECK(built_in_default IN (0, 1)),
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -7001,6 +7061,122 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 24) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
ALTER TABLE channel_outbox
RENAME TO channel_outbox_legacy;
DROP INDEX IF EXISTS channel_outbox_state_created;
CREATE TABLE channel_outbox (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
event_id TEXT NOT NULL,
message_json TEXT NOT NULL,
state TEXT NOT NULL
CHECK(
state IN (
'pending', 'delivered', 'failed', 'terminal'
)
),
attempts INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO channel_outbox
(id, channel, event_id, message_json, state, attempts,
created_at)
SELECT id, channel, event_id,
CASE
WHEN state = 'failed' AND attempts >= 5
THEN json_remove(message_json, '$.attachments')
ELSE message_json
END,
CASE
WHEN state = 'failed' AND attempts >= 5
THEN 'terminal'
ELSE state
END,
attempts, created_at
FROM channel_outbox_legacy;
DROP TABLE channel_outbox_legacy;
CREATE INDEX channel_outbox_state_created
ON channel_outbox(state, created_at);
PRAGMA user_version = 24;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
if (version.user_version < 25) {
const projectColumns = new Set(
(
database.prepare('PRAGMA table_info(projects)').all() as Array<{
name: string
}>
).map((column) => column.name)
)
database.exec('BEGIN IMMEDIATE')
try {
if (!projectColumns.has('built_in_default')) {
database.exec(`
ALTER TABLE projects
ADD COLUMN built_in_default INTEGER NOT NULL DEFAULT 0
CHECK(built_in_default IN (0, 1));
`)
}
database.exec('UPDATE projects SET built_in_default = 0')
const legacyCandidates = database
.prepare(
`SELECT id
FROM projects
WHERE name = ?
AND description = ?
AND kind = 'user'
AND channel IS NULL
AND status = 'active'
AND default_work_mode = 'ask'
AND runtime_selection_json IS NULL
AND created_at = updated_at
LIMIT 2`
)
.all(
builtInDefaultProjectSeedName,
builtInDefaultProjectSeedDescription
) as Array<{ id: string }>
const originalProject = database
.prepare(
`SELECT id
FROM projects
WHERE rowid = 1`
)
.get() as { id: string } | undefined
if (
legacyCandidates.length === 1 &&
legacyCandidates[0]!.id === originalProject?.id
) {
database
.prepare(
`UPDATE projects
SET built_in_default = 1
WHERE id = ?`
)
.run(legacyCandidates[0]!.id)
}
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS
projects_built_in_default_unique
ON projects(built_in_default)
WHERE built_in_default = 1;
PRAGMA user_version = 25;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count
check.close()
migrated.close()
expect(version).toBe(23)
expect(version).toBe(25)
expect(heartbeatTableCount).toBe(4)
})
+2 -1
View File
@@ -74,7 +74,7 @@ export class MemoryDedupStore implements DedupStore {
export type OutboxEntry = {
id: string
message: ChannelResultMessage
state: 'pending' | 'delivered' | 'failed'
state: 'pending' | 'delivered' | 'failed' | 'terminal'
attempts: number
createdAt: number
}
@@ -129,6 +129,7 @@ export class MemoryOutbox implements Outbox {
entry.state = 'failed'
entry.attempts += 1
if (entry.attempts >= 5) {
entry.state = 'terminal'
entry.message = this.withoutAttachments(entry.message)
}
}
+43
View File
@@ -432,6 +432,49 @@ describe('ChannelService', () => {
await service.stop()
})
it('reports terminal outbox entries without retrying them', async () => {
const driver = new FakeChannelDriver()
const outbox = new MemoryOutbox()
const entry = outbox.enqueue({
channel: driver.channel,
eventId: 'terminal-delivery',
conversationId: 'conversation-1',
recipientId: 'allowed-user',
status: 'completed',
output: '完成'
})
for (let attempt = 0; attempt < 5; attempt += 1) {
outbox.markFailed(entry.id)
}
const deliveryFailure = vi.fn()
const service = new ChannelService(
driver,
async () => ({ status: 'completed' }),
{
allowedSenderIds: ['allowed-user'],
outbox,
onDeliveryFailure: deliveryFailure
}
)
await service.start()
expect(driver.sent).toEqual([])
expect(deliveryFailure).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('已达到重试上限')
})
)
expect(await outbox.listUndelivered()).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5
})
])
await service.stop()
})
it('releases the event claim when no durable result can be queued', async () => {
const driver = new FakeChannelDriver()
const store = new MemoryDedupStore()
+6 -1
View File
@@ -198,7 +198,12 @@ export class ChannelService {
if (this.state !== 'running') {
return
}
if (entry.attempts >= 5) {
if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
`通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
)
)
continue
}
try {
+123
View File
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
@@ -186,6 +188,26 @@ afterEach(async () => {
})
describe('DocumentOcrModelManager', () => {
it('reads active progress without creating or scanning model storage', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-document-ocr-progress-')
)
temporaryDirectories.push(directory)
const getDownloadSource = vi.fn(() => 'modelscope' as const)
const manager = new DocumentOcrModelManager({
userDataDirectory: directory,
fetch: vi.fn<typeof fetch>(),
catalog: [],
getDownloadSource
})
expect(manager.getProgressSnapshot()).toEqual({ operations: [] })
expect(getDownloadSource).not.toHaveBeenCalled()
await expect(
readdir(join(directory, 'models', 'document-ocr'))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('reports a removed catalog model as unavailable', async () => {
const { manager } = await createManager()
@@ -323,6 +345,107 @@ describe('DocumentOcrModelManager', () => {
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
})
it('revalidates externally changed OCR files after a successful status check', async () => {
const { directory, manager, modelBytes } = await createManager()
await manager.install('pp-ocrv6-tiny')
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: true,
verified: true
})
await writeFile(
join(
directory,
'models',
'document-ocr',
'pp-ocrv6-tiny',
'detection.onnx'
),
Buffer.alloc(modelBytes.detection.byteLength, 0x7f)
)
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: false,
verified: false
})
})
it('does not let an invalidated verification survive remove and reinstall', async () => {
const { manager } = await createManager()
await manager.install('pp-ocrv6-tiny')
const checking = manager.getStatus('pp-ocrv6-tiny')
await manager.remove('pp-ocrv6-tiny')
await expect(checking).resolves.toMatchObject({
available: false,
verified: false
})
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: false,
verified: false
})
await manager.install('pp-ocrv6-tiny')
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: true,
verified: true
})
})
it('cleans only manager-owned stale staging and partial artifacts', async () => {
const { directory, manager, modelBytes } = await createManager()
await manager.install('pp-ocrv6-tiny')
const root = join(directory, 'models', 'document-ocr')
const modelDirectory = join(root, 'pp-ocrv6-tiny')
const staleStaging =
'.install-pp-ocrv6-tiny-00000000-0000-4000-8000-000000000001'
const unrelatedStaging = '.install-pp-ocrv6-tiny-user-backup'
await mkdir(join(root, staleStaging))
await writeFile(
join(root, staleStaging, 'detection.onnx.partial'),
'stale'
)
await mkdir(join(root, unrelatedStaging))
await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep')
await writeFile(
join(modelDirectory, 'detection.onnx.partial'),
'interrupted'
)
await writeFile(join(modelDirectory, 'notes.partial'), 'keep')
await writeFile(join(root, 'user.partial'), 'keep')
await expect(manager.getSnapshot()).resolves.toMatchObject({
installed: [expect.objectContaining({ id: 'pp-ocrv6-tiny' })]
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([
'pp-ocrv6-tiny',
unrelatedStaging,
'user.partial'
])
)
expect(await readdir(root)).not.toContain(staleStaging)
expect(await readdir(modelDirectory)).toEqual(
expect.arrayContaining([
'manifest.json',
'detection.onnx',
'recognition.onnx',
'dictionary.yml',
'notes.partial'
])
)
expect(await readdir(modelDirectory)).not.toContain(
'detection.onnx.partial'
)
await expect(
readFile(join(modelDirectory, 'detection.onnx'))
).resolves.toEqual(Buffer.from(modelBytes.detection))
await expect(
readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
})
it('downloads the same canonical package from Hugging Face', async () => {
const { manager } = await createManager()
+117 -95
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { createHash } from 'node:crypto'
import {
copyFile,
lstat,
@@ -11,11 +11,12 @@ import {
stat,
writeFile
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import {
documentOcrAssetsSchema,
documentOcrModelCatalogEntrySchema,
documentOcrModelCatalogViewEntrySchema,
documentOcrModelProgressSnapshotSchema,
documentOcrModelSnapshotSchema,
documentParsingModelStatusSchema,
installedDocumentOcrModelSchema,
@@ -25,6 +26,7 @@ import {
type DocumentOcrModelCatalogViewEntry,
type DocumentOcrModelFile,
type DocumentOcrModelOperation,
type DocumentOcrModelProgressSnapshot,
type DocumentOcrModelSnapshot,
type InstalledDocumentOcrModel
} from '../shared/document-parsing-contracts'
@@ -41,10 +43,20 @@ import {
extractModelArchive
} from './model-archive'
import { fetchModelDownloadResponse } from './model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
hashModelFile,
managedModelChild,
writeModelBuffer
} from './model-package-utils'
import { isMissingFileError } from './settings-file-utils'
const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
const executableExtensionPattern =
@@ -55,6 +67,11 @@ type ActiveOperation = {
progress: DocumentOcrModelOperation
}
type ActiveVerification = {
generation: number
promise: Promise<void>
}
export type DocumentOcrModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
@@ -65,16 +82,6 @@ export type DocumentOcrModelManagerOptions = {
maxFileBytes?: number
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError()
}
}
function cloneCatalogEntry(
entry: DocumentOcrModelCatalogEntry
): DocumentOcrModelCatalogEntry {
@@ -99,43 +106,17 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('OCR 模型路径超出受管目录')
}
return child
return managedModelChild(
parent,
name,
'OCR 模型路径超出受管目录'
)
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer
}
async function hashFile(
path: string,
signal?: AbortSignal
): Promise<{ size: number; sha256: string }> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
let size = 0
try {
while (true) {
if (signal) {
ensureNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
}
function parseYamlScalar(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/''/gu, "'")
@@ -184,7 +165,8 @@ export class DocumentOcrModelManager {
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
private readonly verifiedModels = new Map<string, Promise<void>>()
private readonly verifiedModels = new Map<string, ActiveVerification>()
private readonly verificationGenerations = new Map<string, number>()
constructor(options: DocumentOcrModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
@@ -220,6 +202,7 @@ export class DocumentOcrModelManager {
async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(),
this.readInstalled()
@@ -235,6 +218,14 @@ export class DocumentOcrModelManager {
})
}
getProgressSnapshot(): DocumentOcrModelProgressSnapshot {
return documentOcrModelProgressSnapshotSchema.parse({
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
}))
})
}
async getStatus(
modelId: string
): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> {
@@ -317,7 +308,7 @@ export class DocumentOcrModelManager {
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
await this.downloadFile(
@@ -335,10 +326,10 @@ export class DocumentOcrModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
detachAbort()
@@ -373,7 +364,7 @@ export class DocumentOcrModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
@@ -391,10 +382,10 @@ export class DocumentOcrModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
detachAbort()
@@ -521,10 +512,10 @@ export class DocumentOcrModelManager {
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
this.operations.delete(entry.id)
@@ -547,7 +538,7 @@ export class DocumentOcrModelManager {
async remove(modelId: string): Promise<void> {
const id = localOcrModelIdSchema.parse(modelId)
this.cancel(id)
this.verifiedModels.delete(id)
this.invalidateVerification(id)
await rm(this.modelDirectory(id), {
recursive: true,
force: true
@@ -560,6 +551,7 @@ export class DocumentOcrModelManager {
}
this.operations.clear()
this.verifiedModels.clear()
this.verificationGenerations.clear()
}
private async ensureRoot(): Promise<void> {
@@ -613,16 +605,7 @@ export class DocumentOcrModelManager {
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
return attachModelAbortSignal(signal, controller)
}
private async assertNotInstalled(modelId: string): Promise<void> {
@@ -630,11 +613,7 @@ export class DocumentOcrModelManager {
await lstat(this.modelDirectory(modelId))
throw new Error('OCR 模型已安装')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return
}
throw error
@@ -642,12 +621,11 @@ export class DocumentOcrModelManager {
}
private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild(
return createModelStagingDirectory(
this.rootDirectory,
`.install-${modelId}-${randomUUID()}`
modelId,
'OCR 模型路径超出受管目录'
)
await mkdir(directory, { recursive: false })
return directory
}
private async downloadFile(
@@ -682,29 +660,34 @@ export class DocumentOcrModelManager {
throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
}
const partialPath = `${destination}${PARTIAL_SUFFIX}`
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > file.size ||
written > this.maxFileBytes
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`OCR 模型文件过大:${file.name}`)
}
await handle.write(result.value)
hash.update(result.value)
operation.progress.completedBytes += result.value.byteLength
const persistedBytes = await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
}
} catch (error) {
await reader.cancel().catch(() => undefined)
@@ -732,7 +715,7 @@ export class DocumentOcrModelManager {
}
const entries = await readdir(sourceDirectory, { withFileTypes: true })
for (const localEntry of entries) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
if (
localEntry.isSymbolicLink() ||
executableExtensionPattern.test(localEntry.name)
@@ -741,13 +724,13 @@ export class DocumentOcrModelManager {
}
}
for (const file of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const path = safeChild(sourceDirectory, file.name)
const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`OCR 模型文件必须是普通文件:${file.name}`)
}
const actual = await hashFile(path, signal)
const actual = await hashModelFile(path, signal)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256
@@ -765,11 +748,11 @@ export class DocumentOcrModelManager {
): Promise<InstalledDocumentOcrModel> {
const files = []
for (const file of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
files.push({
name: file.name,
role: file.role,
...(await hashFile(
...(await hashModelFile(
safeChild(stagingDirectory, file.name),
signal
))
@@ -854,7 +837,9 @@ export class DocumentOcrModelManager {
candidate.name === file.name &&
candidate.role === file.role
)
const actual = await hashFile(safeChild(directory, file.name))
const actual = await hashModelFile(
safeChild(directory, file.name)
)
if (
!installed ||
actual.size !== file.size ||
@@ -870,15 +855,37 @@ export class DocumentOcrModelManager {
private getVerifiedStatus(
entry: DocumentOcrModelCatalogEntry
): Promise<void> {
let verification = this.verifiedModels.get(entry.id)
if (!verification) {
verification = this.verifyInstalledModel(entry).catch((error) => {
this.verifiedModels.delete(entry.id)
throw error
})
this.verifiedModels.set(entry.id, verification)
const generation = this.verificationGenerations.get(entry.id) ?? 0
const active = this.verifiedModels.get(entry.id)
if (active?.generation === generation) {
return active.promise
}
return verification
const verification = this.verifyInstalledModel(entry).then(() => {
if (
(this.verificationGenerations.get(entry.id) ?? 0) !==
generation
) {
throw new Error('OCR 模型在校验期间已发生变化')
}
})
const tracked = verification.finally(() => {
if (this.verifiedModels.get(entry.id)?.promise === tracked) {
this.verifiedModels.delete(entry.id)
}
})
this.verifiedModels.set(entry.id, {
generation,
promise: tracked
})
return tracked
}
private invalidateVerification(modelId: string): void {
this.verificationGenerations.set(
modelId,
(this.verificationGenerations.get(modelId) ?? 0) + 1
)
this.verifiedModels.delete(modelId)
}
private async loadVerifiedAssets(
@@ -932,4 +939,19 @@ export class DocumentOcrModelManager {
dictionary: loaded.get('dictionary')
})
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) =>
localOcrModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
escapeMessage: 'OCR 模型路径超出受管目录'
})
}
}
+165 -74
View File
@@ -49,10 +49,7 @@ import {
} from './window'
import { createTrayIcon } from './tray-icon'
import { resolveBundledRuntimePaths } from './agent/bundled-runtimes'
import type {
ContinueHostChild,
ContinueHostLauncher
} from './agent/continue-host-adapter'
import type { ContinueHostLauncher } from './agent/continue-host-adapter'
import { resolvePortableUserDataPath } from './portable-user-data'
import { BrowserService } from './browser/browser-service'
import { SubagentService } from './assistant/subagent-service'
@@ -83,7 +80,11 @@ import {
type DeepSeekHarnessFork
} from './agent/deepseek-harness-utility-launcher'
import { buildControlledHarnessEnvironment } from './agent/process-environment'
import { runStartupPrerequisites } from './startup-prerequisites'
import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
runStartupPrerequisites
} from './startup-prerequisites'
import { RuntimeExtensionStore } from './agent/runtime-extension-store'
import {
DshNpmExtensionInstaller,
@@ -95,8 +96,14 @@ import {
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId
} from './windows-notification-identity'
import { ShortcutSettingsStore } from './shortcut-settings-store'
import { ShortcutSettingsService } from './shortcut-settings-service'
import { defaultGlobalShortcutSettings } from '../shared/shortcut'
import { requestProcessTreeTermination } from './agent/child-process-termination'
import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter'
const shortcut = 'CommandOrControl+Shift+Space'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
const portableUserDataPath = resolvePortableUserDataPath({
packaged: app.isPackaged,
@@ -205,39 +212,28 @@ const launchContinueHost: ContinueHostLauncher = (
stdio: 'pipe'
}
)
let exitCode: number | null = null
let killed = false
utilityChild.on('exit', (code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
return createContinueUtilityProcessChild({
get pid() {
return utilityChild.pid
},
stderr: utilityChild.stderr,
once: (_event, listener) => {
utilityChild.once('error', (_type, location, report) => {
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
})
return child
kill: () => utilityChild.kill(),
onExit: (listener) => {
utilityChild.on('exit', listener)
},
kill: () => {
killed = true
return utilityChild.kill()
onceExit: (listener) => {
utilityChild.once('exit', listener)
},
onceError: (listener) => {
utilityChild.once('error', listener)
},
removeExitListener: (listener) => {
utilityChild.removeListener('exit', listener)
},
removeErrorListener: (listener) => {
utilityChild.removeListener('error', listener)
}
}
return child
})
}
const forkDeepSeekHarness: DeepSeekHarnessFork = (
@@ -254,20 +250,7 @@ const forkDeepSeekHarness: DeepSeekHarnessFork = (
function terminateHarnessUtilityProcess(
child: ReturnType<DeepSeekHarnessFork>
): void {
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill()
requestProcessTreeTermination(child, { spawn })
}
const launchWechatSidecar: WechatSidecarLauncher = () => {
@@ -521,6 +504,12 @@ if (hasSingleInstanceLock) {
parseDocument: documentParsingService.parse
})
knowledgeService = startupKnowledgeService
let activeEmbeddingProvider:
| ReturnType<typeof createEmbeddingProvider>
| undefined
let activeRerankProvider:
| ReturnType<typeof createRerankProvider>
| undefined
const startupAssistantDatabase = new AssistantDatabase(
join(app.getPath('userData'), 'assistant.sqlite')
)
@@ -617,13 +606,29 @@ if (hasSingleInstanceLock) {
},
initializeKnowledgeAndGateway: async () => {
await startupKnowledgeService.initialize()
const embeddingProvider = createEmbeddingProvider(
initialResolvedSettings
)
const rerankProvider = createRerankProvider(
initialResolvedSettings
)
await Promise.all([
startupKnowledgeService.setEmbeddingProvider(
createEmbeddingProvider(initialResolvedSettings)
).catch(() => undefined),
embeddingProvider
).then(
() => {
activeEmbeddingProvider = embeddingProvider
},
() => undefined
),
startupKnowledgeService.setRerankProvider(
createRerankProvider(initialResolvedSettings)
).catch(() => undefined)
rerankProvider
).then(
() => {
activeRerankProvider = rerankProvider
},
() => undefined
)
])
await startupKnowledgeGateway.start()
},
@@ -663,11 +668,19 @@ if (hasSingleInstanceLock) {
})
const approvalBroker = new ToolApprovalBroker()
const shortcutRegistered = globalShortcut.register(shortcut, () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
const shortcutSettingsService = new ShortcutSettingsService(
new ShortcutSettingsStore(
join(app.getPath('userData'), 'shortcut-settings.json')
),
globalShortcut,
() => {
if (mainWindow) {
toggleWindow(mainWindow)
}
},
process.platform
)
await shortcutSettingsService.initialize()
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
let runtimeReconfigurationClosing = false
@@ -677,24 +690,97 @@ if (hasSingleInstanceLock) {
throw new Error('Runtime 配置正在关闭')
}
const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) {
await knowledgeService.setEmbeddingProvider(
createEmbeddingProvider(settings)
)
await knowledgeService.setRerankProvider(
createRerankProvider(settings)
const nextEmbeddingProvider =
createEmbeddingProvider(settings)
const nextRerankProvider = createRerankProvider(settings)
let nextRuntime: AgentRuntime | undefined
let nextSubagentRuntime: AgentRuntime | undefined
let nextSubagentProfileRuntimes:
| ReadonlyMap<string, AgentRuntime>
| undefined
try {
nextSubagentRuntime = createDefaultModelRuntime(
defaultWorkspace,
settings
)
nextSubagentProfileRuntimes =
createSubagentProfileRuntimes(
defaultWorkspace,
settings
)
if (runtime) {
nextRuntime = await createConfiguredRuntime(settings)
}
} catch (error) {
await Promise.allSettled([
nextRuntime?.dispose(),
nextSubagentRuntime?.dispose(),
...[
...(nextSubagentProfileRuntimes?.values() ?? [])
].map((candidate) => candidate.dispose())
])
throw error
}
if (runtime) {
await runtime.replace(
await createConfiguredRuntime(settings)
let runtimeConsumed = false
let subagentRuntimesConsumed = false
try {
if (knowledgeService) {
await Promise.all([
knowledgeService.setEmbeddingProvider(
nextEmbeddingProvider
),
knowledgeService.setRerankProvider(
nextRerankProvider
)
])
}
if (runtime && nextRuntime) {
runtimeConsumed = true
await runtime.replace(nextRuntime)
}
subagentRuntimesConsumed = true
await subagentService.replaceRuntimes(
nextSubagentRuntime,
nextSubagentProfileRuntimes
)
await selectedRuntimeManager?.reset()
activeEmbeddingProvider = nextEmbeddingProvider
activeRerankProvider = nextRerankProvider
} catch (activationError) {
const rollbackResults = knowledgeService
? await Promise.allSettled([
knowledgeService.setEmbeddingProvider(
activeEmbeddingProvider
),
knowledgeService.setRerankProvider(
activeRerankProvider
)
])
: []
await Promise.allSettled([
runtimeConsumed ? undefined : nextRuntime?.dispose(),
subagentRuntimesConsumed
? undefined
: nextSubagentRuntime.dispose(),
...(subagentRuntimesConsumed
? []
: [...nextSubagentProfileRuntimes.values()].map(
(candidate) => candidate.dispose()
))
])
const rollbackErrors = rollbackResults.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (rollbackErrors.length > 0) {
throw new AggregateError(
[activationError, ...rollbackErrors],
'Runtime 激活失败,且模型服务回滚未能完成',
{ cause: activationError }
)
}
throw activationError
}
await selectedRuntimeManager?.reset()
await subagentService.replaceRuntimes(
createDefaultModelRuntime(defaultWorkspace, settings),
createSubagentProfileRuntimes(defaultWorkspace, settings)
)
})
runtimeReconfigurationQueue = operation.catch(() => undefined)
return operation
@@ -707,7 +793,7 @@ if (hasSingleInstanceLock) {
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
shortcutRegistered ? shortcut : '未注册',
legacyDefaultShortcut,
settingsStore,
capabilityService,
contextManager,
@@ -735,7 +821,8 @@ if (hasSingleInstanceLock) {
documentOcrBroker,
releaseNotesService,
goodbuddyConfigService,
runtimeExtensionStore
runtimeExtensionStore,
shortcutSettingsService
)
loadMainWindow(mainWindow)
setImmediate(() => {
@@ -786,10 +873,14 @@ if (hasSingleInstanceLock) {
showWindow(mainWindow)
}
})
}).catch(() => {
}).catch((error: unknown) => {
console.error(
'GoodBuddy startup failed',
createStartupFailureDiagnostic(error)
)
dialog.showErrorBox(
'GoodBuddy 启动失败',
'本地数据或 Runtime 服务初始化失败。请重启应用;若问题持续,请备份后清理应用数据。'
formatStartupFailureMessage(error)
)
app.quit()
})
+1076 -14
View File
File diff suppressed because it is too large Load Diff
+280 -125
View File
@@ -16,7 +16,10 @@ import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import { formatShortcutForDisplay } from '../shared/shortcut'
import {
formatShortcutForDisplay,
globalShortcutSettingsUpdateSchema
} from '../shared/shortcut'
import { readBoundedFile } from './workspace-file-access'
import {
approvalDecisionSchema,
@@ -203,6 +206,7 @@ import {
type RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts'
import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
import type { ShortcutSettingsService } from './shortcut-settings-service'
import type { ContextManager } from './context-manager'
import type { KnowledgeService } from './knowledge/knowledge-service'
import {
@@ -274,6 +278,7 @@ import {
import { AgentEventBuffer } from './agent-event-buffer'
const requestIdSchema = z.string().uuid()
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
const runtimeConfigFileMetadata = {
opencode: {
filterName: 'OpenCode 配置',
@@ -415,6 +420,31 @@ function safeRuntimeError(error: unknown, fallback: string): string {
return safeToolErrorDetail(error, 2_000) ?? fallback
}
async function activateOrRollback<T>(input: {
previous: T
persistCandidate(): Promise<T>
activate(): Promise<void>
persistPrevious(previous: T): Promise<unknown>
}): Promise<T> {
const saved = await input.persistCandidate()
try {
await input.activate()
return saved
} catch (activationError) {
try {
await input.persistPrevious(input.previous)
await input.activate()
} catch (rollbackError) {
throw new AggregateError(
[activationError, rollbackError],
'Runtime 配置激活失败,且回滚未能完成',
{ cause: rollbackError }
)
}
throw activationError
}
}
function createPromiseTracker(): {
track<T>(operation: Promise<T>): Promise<T>
drain(): Promise<void>
@@ -883,10 +913,35 @@ export function registerIpcHandlers(
documentOcrBroker?: DocumentOcrBroker,
releaseNotesService?: ReleaseNotesService,
goodbuddyConfigService?: GoodBuddyConfigService,
runtimeExtensionStore?: RuntimeExtensionStore
runtimeExtensionStore?: RuntimeExtensionStore,
shortcutSettingsService?: ShortcutSettingsService
): () => Promise<void> {
const activeRequests = new Map<string, AbortController>()
const activeRequestConversations = new Map<string, string>()
type ActiveRequestLease = {
controller: AbortController
conversationId: string
}
const activeRequests = new Map<string, ActiveRequestLease>()
const activeRequestConversations = new Map<
string,
ActiveRequestLease
>()
const leaseActiveRequest = (
requestId: string,
conversationId: string,
controller: AbortController
): (() => void) => {
const lease = { controller, conversationId }
activeRequests.set(requestId, lease)
activeRequestConversations.set(requestId, lease)
return (): void => {
if (activeRequests.get(requestId) === lease) {
activeRequests.delete(requestId)
}
if (activeRequestConversations.get(requestId) === lease) {
activeRequestConversations.delete(requestId)
}
}
}
const activeEventBuffers = new Map<string, { flush(): void }>()
const pendingAgentQuestions = new Map<
string,
@@ -900,6 +955,17 @@ export function registerIpcHandlers(
const pendingRendererPersistence = new Map<string, () => void>()
let pendingGoodBuddyConfigReload = false
let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve()
let runtimeSettingsUpdateQueue: Promise<void> = Promise.resolve()
const enqueueRuntimeSettingsUpdate = <T>(
transaction: () => Promise<T>
): Promise<T> => {
const result = runtimeSettingsUpdateQueue.then(transaction)
runtimeSettingsUpdateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
const executionTracker = createPromiseTracker()
const maintenanceTracker = createPromiseTracker()
const trackExecution = executionTracker.track
@@ -1010,8 +1076,8 @@ export function registerIpcHandlers(
}
})
const abortActiveRequests = (reason: string): void => {
for (const controller of activeRequests.values()) {
controller.abort(new Error(reason))
for (const lease of activeRequests.values()) {
lease.controller.abort(new Error(reason))
}
activeRequests.clear()
}
@@ -1316,7 +1382,7 @@ export function registerIpcHandlers(
(candidate) => candidate === conversationId
) ||
[...activeRequestConversations.values()].some(
(candidate) => candidate === conversationId
(candidate) => candidate.conversationId === conversationId
)
const pumpConversationQueue = async (
@@ -1538,14 +1604,17 @@ export function registerIpcHandlers(
externalSignal?.addEventListener('abort', abortFromExternal, {
once: true
})
activeRequests.set(requestId, controller)
const runtimeConversationId =
remoteContext?.conversationId ??
(input.origin === 'schedule'
? input.schedule.conversationId
: undefined) ??
`${origin}:${schedule.id}`
activeRequestConversations.set(requestId, runtimeConversationId)
const releaseActiveRequest = leaseActiveRequest(
requestId,
runtimeConversationId,
controller
)
if (input.origin !== 'delegation') {
assistantDatabase.updateTaskStatus(taskId, 'running')
} else {
@@ -1565,6 +1634,7 @@ export function registerIpcHandlers(
}
let output = ''
let completed = false
let backgroundQuestionError: Error | undefined
let knowledgeCapabilityToken: string | undefined
const resultAttachments: ChannelMediaAttachment[] = []
const artifactIds: string[] = []
@@ -1766,6 +1836,37 @@ export function registerIpcHandlers(
if (taskEvent.type === 'artifact') {
artifactIds.push(taskEvent.artifactId)
}
if (taskEvent.type === 'question') {
const error = new Error(
'后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。'
)
backgroundQuestionError = error
const rejection =
requestRuntime
.respondToQuestion?.(taskEvent.questionId)
.catch(() => undefined) ?? Promise.resolve()
let rejectionTimeout:
| ReturnType<typeof setTimeout>
| undefined
try {
await Promise.race([
rejection,
new Promise<void>((resolveTimeout) => {
rejectionTimeout = setTimeout(
resolveTimeout,
BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS
)
rejectionTimeout.unref?.()
})
])
} finally {
if (rejectionTimeout) {
clearTimeout(rejectionTimeout)
}
}
controller.abort(error)
throw error
}
eventBuffer.push(taskEvent)
if (taskEvent.type === 'tool' && remoteContext) {
publishRemoteActivity({
@@ -1879,8 +1980,11 @@ export function registerIpcHandlers(
}
} catch (error) {
eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败')
const cancelled = controller.signal.aborted
const message = backgroundQuestionError
? backgroundQuestionError.message
: safeRuntimeError(error, '定时任务执行失败')
const cancelled =
controller.signal.aborted && !backgroundQuestionError
assistantDatabase.updateTaskStatus(
taskId,
cancelled ? 'cancelled' : 'failed',
@@ -1924,8 +2028,7 @@ export function registerIpcHandlers(
)
knowledgeGateway?.revoke(knowledgeCapabilityToken)
goodbuddyConfigService?.revokeRequest(requestId)
activeRequests.delete(requestId)
activeRequestConversations.delete(requestId)
releaseActiveRequest()
await flushGoodBuddyConfigReload().catch(() => undefined)
}
}
@@ -2297,6 +2400,34 @@ export function registerIpcHandlers(
detail: parsed.prompt,
status: 'running'
})
const finalizeExecutePreflightFailure = (
unavailable: string
): { status: 'failed'; error: string } => {
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
}
let executionRuntime: AgentRuntime | undefined
if (parsed.workMode === 'execute') {
@@ -2316,30 +2447,7 @@ export function registerIpcHandlers(
error,
'远程 Execute Runtime 不可用'
)
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
return finalizeExecutePreflightFailure(unavailable)
}
if (
!executionStatus.available ||
@@ -2349,30 +2457,7 @@ export function registerIpcHandlers(
? '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型'
: executionStatus.detail?.trim() ||
'所选处理后端当前不可用,请在消息通道设置中检查 Runtime 或模型连接'
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
return finalizeExecutePreflightFailure(unavailable)
}
}
@@ -2499,12 +2584,20 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.appInfo, (event): AppInfo => {
assertTrustedSender(event, window)
const shortcutSnapshot = shortcutSettingsService?.getSnapshot()
return {
name: app.getName(),
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
shortcut: formatShortcutForDisplay(shortcut, process.platform)
shortcut: shortcutSnapshot?.registered
? shortcutSnapshot.displayAccelerator
: shortcutSnapshot
? ''
: formatShortcutForDisplay(shortcut, process.platform),
...(shortcutSnapshot
? { shortcutStatus: shortcutSnapshot.status }
: {})
}
})
@@ -2673,8 +2766,8 @@ export function registerIpcHandlers(
reservedConversationQueueItems.get(parsedInput.conversationId)
if (
[...activeRequestConversations.values()].some(
(conversationId) =>
conversationId === parsedInput.conversationId
(lease) =>
lease.conversationId === parsedInput.conversationId
) ||
[...preparingRequestConversations.values()].some(
(conversationId) =>
@@ -2730,39 +2823,49 @@ export function registerIpcHandlers(
const enrichedRequest = contextManager.enrichRequest(
parsedRequest
)
const hasKnowledgeScope = knowledgeLibraryIds.length > 0
const magicNotesToolEnabled =
(await applicationSettingsStore?.get())?.magicNotesEnabled ?? false
const webSearchEnabled =
!agentRuntimeSelected &&
(
await capabilityService.getWebSearchCapabilityStatus?.()
)?.enabled === true
if (activeRequests.has(enrichedRequest.requestId)) {
throw new Error('请求正在执行')
}
const controller = new AbortController()
const hasKnowledgeScope = knowledgeLibraryIds.length > 0
const configAccess =
goodbuddyConfigService && !imageGeneration
? enrichedRequest.workMode === 'execute'
? 'write'
: 'read'
: 'none'
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const [
applicationSettings,
webSearchCapability,
resolvedRuntimeSettings,
enabledBuiltinMcpServers
] = await Promise.all([
applicationSettingsStore?.get(),
!agentRuntimeSelected
? capabilityService.getWebSearchCapabilityStatus?.()
: undefined,
configAccess !== 'none' && !enrichedRequest.projectId
? settingsStore.getResolvedSettings()
: undefined,
selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
])
const magicNotesToolEnabled =
applicationSettings?.magicNotesEnabled ?? false
const webSearchEnabled =
webSearchCapability?.enabled === true
const configWorkspacePath =
configAccess === 'none'
? undefined
: enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const enabledBuiltinMcpServers = selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
: resolvedRuntimeSettings?.workspacePath
const controller = new AbortController()
const scopedCapability = grantScopedDataCapability({
gateway: knowledgeGateway,
runtime: selectedRuntime,
@@ -2823,10 +2926,10 @@ export function registerIpcHandlers(
knowledgeGateway?.revoke(knowledgeCapabilityToken)
throw error
}
activeRequests.set(request.requestId, controller)
activeRequestConversations.set(
const releaseActiveRequest = leaseActiveRequest(
request.requestId,
request.conversationId
request.conversationId,
controller
)
if (parsedInput.queueItemId) {
const dispatchTimeout = queueDispatchTimers.get(
@@ -2848,8 +2951,7 @@ export function registerIpcHandlers(
)
publishConversationQueueChange(request.conversationId)
} catch (error) {
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
assistantDatabase.updateTaskStatus(
request.requestId,
'cancelled',
@@ -3336,8 +3438,7 @@ export function registerIpcHandlers(
}
}
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
const configReload =
goodbuddyConfigService?.takePendingReload(request.requestId) ??
'none'
@@ -3360,7 +3461,9 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.agentCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
const requestId = requestIdSchema.parse(input)
activeRequests.get(requestId)?.abort(new Error('用户取消了请求'))
activeRequests
.get(requestId)
?.controller.abort(new Error('用户取消了请求'))
})
registerHandler(ipcChannels.agentApprovalRespond, (event, input: unknown) => {
@@ -3453,10 +3556,10 @@ export function registerIpcHandlers(
),
5 * 60_000
)
activeRequests.set(request.requestId, controller)
activeRequestConversations.set(
const releaseActiveRequest = leaseActiveRequest(
request.requestId,
request.conversationId
request.conversationId,
controller
)
assistantDatabase.createTask({
id: request.requestId,
@@ -3541,8 +3644,7 @@ export function registerIpcHandlers(
throw error
} finally {
clearTimeout(timeout)
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId)
}
@@ -3571,12 +3673,23 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const settings =
runtimeCustomizationSettingsSchema.parse(input)
const saved =
await settingsStore.updateRuntimeCustomization(settings)
abortActiveRequests('Runtime 定制设置已更改')
approvalBroker.clear()
await onRuntimeSettingsChanged()
return saved
return enqueueRuntimeSettingsUpdate(async () => {
const previous =
await settingsStore.getRuntimeCustomization()
return activateOrRollback({
previous,
persistCandidate: async () => {
const saved =
await settingsStore.updateRuntimeCustomization(settings)
abortActiveRequests('Runtime 定制设置已更改')
approvalBroker.clear()
return saved
},
activate: onRuntimeSettingsChanged,
persistPrevious: (previousSettings) =>
settingsStore.updateRuntimeCustomization(previousSettings)
})
})
}
)
@@ -3611,28 +3724,39 @@ export function registerIpcHandlers(
async (event, input: unknown): Promise<RuntimeSettings> => {
assertTrustedSender(event, window)
const settings = runtimeSettingsInputSchema.parse(input)
let workspacePath: string
try {
workspacePath = await realpath(settings.workspacePath)
if (!(await stat(workspacePath)).isDirectory()) {
throw new Error('Not a directory')
return enqueueRuntimeSettingsUpdate(async () => {
let workspacePath: string
try {
workspacePath = await realpath(settings.workspacePath)
if (!(await stat(workspacePath)).isDirectory()) {
throw new Error('Not a directory')
}
} catch {
throw new Error('所选工作区不存在、不可访问或不是文件夹')
}
} catch {
throw new Error('所选工作区不存在、不可访问或不是文件夹')
}
const savedSettings = await settingsStore.update({
...settings,
workspacePath
})
channelSettingsStore?.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
savedSettings
const rollback = await settingsStore.captureRollback()
const previousSettings = rollback.publicSettings
const savedSettings = await activateOrRollback({
previous: previousSettings,
persistCandidate: async () => {
const saved = await settingsStore.update({
...settings,
workspacePath
})
abortActiveRequests('运行时设置已更改')
approvalBroker.clear()
return saved
},
activate: onRuntimeSettingsChanged,
persistPrevious: () => rollback.restore()
})
channelSettingsStore?.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
savedSettings
)
)
)
abortActiveRequests('运行时设置已更改')
approvalBroker.clear()
await onRuntimeSettingsChanged()
return savedSettings
return savedSettings
})
}
)
@@ -3903,6 +4027,27 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.shortcutSettingsGet, (event) => {
assertTrustedSender(event, window)
if (!shortcutSettingsService) {
throw new Error('快捷键设置服务不可用')
}
return shortcutSettingsService.getSnapshot()
})
registerHandler(
ipcChannels.shortcutSettingsUpdate,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!shortcutSettingsService) {
throw new Error('快捷键设置服务不可用')
}
return shortcutSettingsService.update(
globalShortcutSettingsUpdateSchema.parse(input)
)
}
)
registerHandler(ipcChannels.documentParsingGet, (event) => {
assertTrustedSender(event, window)
if (!documentParsingService) {
@@ -3911,6 +4056,14 @@ export function registerIpcHandlers(
return documentParsingService.snapshot()
})
registerHandler(ipcChannels.documentOcrModelsProgress, (event) => {
assertTrustedSender(event, window)
if (!documentOcrModelManager) {
throw new Error('本地 OCR 模型服务不可用')
}
return documentOcrModelManager.getProgressSnapshot()
})
registerHandler(
ipcChannels.documentParsingUpdate,
(event, input: unknown) => {
@@ -4596,11 +4749,13 @@ export function registerIpcHandlers(
}
preferredConversationQueueItems.set(item.conversationId, item.id)
readyConversationQueues.add(item.conversationId)
for (const [requestId, conversationId] of activeRequestConversations) {
if (conversationId === item.conversationId) {
for (const [requestId, lease] of activeRequestConversations) {
if (lease.conversationId === item.conversationId) {
activeRequests
.get(requestId)
?.abort(new Error('用户中断当前回复并插入队列项'))
?.controller.abort(
new Error('用户中断当前回复并插入队列项')
)
}
}
if (!isConversationExecuting(item.conversationId)) {
+41 -1
View File
@@ -77,6 +77,7 @@ describe('model archive', () => {
}
})
const progress: number[] = []
await expect(
extractModelArchive({
archivePath: archive,
@@ -89,7 +90,10 @@ describe('model archive', () => {
],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024,
maximumTotalBytes: 2048
maximumTotalBytes: 2048,
onProgress: (completedBytes) => {
progress.push(completedBytes)
}
})
).resolves.toMatchObject({
kind: 'speech',
@@ -101,6 +105,7 @@ describe('model archive', () => {
await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual(
tokens
)
expect(progress.at(-1)).toBe(model.byteLength + tokens.byteLength)
})
it('preserves an existing archive when source verification fails', async () => {
@@ -208,4 +213,39 @@ describe('model archive', () => {
})
).rejects.toThrow('模型 ID 不匹配')
})
it('handles malformed entry rejection without an unhandled promise', async () => {
const directory = await temporaryDirectory()
const archive = join(directory, 'truncated.zip')
const extracted = join(directory, 'extracted')
await mkdir(extracted)
const complete = zipSync({
'goodbuddy-model.json': Buffer.from('{}'),
'model.onnx': Buffer.alloc(128 * 1024, 7)
})
await writeFile(archive, complete.subarray(0, complete.length - 17))
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason)
}
process.on('unhandledRejection', onUnhandled)
try {
await expect(
extractModelArchive({
archivePath: archive,
destinationDirectory: extracted,
expectedKind: 'speech',
expectedModelId: 'test-model',
expectedFiles: [{ name: 'model.onnx', role: 'model' }],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024 * 1024,
maximumTotalBytes: 1024 * 1024
})
).rejects.toThrow()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(unhandled).toEqual([])
} finally {
process.removeListener('unhandledRejection', onUnhandled)
}
})
})
+57 -52
View File
@@ -7,7 +7,7 @@ import {
rm,
type FileHandle
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import {
Unzip,
UnzipInflate,
@@ -16,6 +16,13 @@ import {
ZipPassThrough
} from 'fflate'
import { z } from 'zod'
import {
ensureModelOperationNotAborted,
hashModelFile,
managedModelChild,
writeModelBuffer
} from './model-package-utils'
import { isMissingFileError } from './settings-file-utils'
const ARCHIVE_MANIFEST_NAME = 'goodbuddy-model.json'
const ARCHIVE_FORMAT = 'goodbuddy-model-archive'
@@ -108,14 +115,6 @@ type ExtractModelArchiveOptions = {
onProgress?: (completedBytes: number) => void
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('模型 ZIP 路径超出临时目录')
}
return child
}
function ensureArchiveName(name: string): string {
return archiveFileNameSchema.parse(name)
}
@@ -127,24 +126,6 @@ function ensureUniqueFiles(files: ModelArchiveExpectedFile[]): void {
}
}
async function hashFile(path: string): Promise<ModelArchiveFile['sha256']> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
try {
while (true) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
}
} finally {
await handle.close()
}
return hash.digest('hex')
}
function checkedLimit(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${label}无效`)
@@ -152,14 +133,6 @@ function checkedLimit(value: number, label: string): number {
return value
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error('模型 ZIP 导入已取消')
}
}
async function pushFileIntoArchive(
archive: Zip,
file: ModelArchiveFile,
@@ -233,7 +206,7 @@ async function replaceArchiveFile(
throw new Error('模型 ZIP 导出目标必须是普通文件')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
if (!isMissingFileError(error)) {
throw error
}
}
@@ -277,7 +250,7 @@ export async function exportModelArchive(
}
writeChain = writeChain.then(async () => {
if (data.byteLength > 0) {
await output.write(data)
await writeModelBuffer(output, data)
}
})
if (final) {
@@ -310,7 +283,11 @@ export async function exportModelArchive(
await pushFileIntoArchive(
archive,
file,
safeChild(sourceDirectory, file.name),
managedModelChild(
sourceDirectory,
file.name,
'模型 ZIP 路径超出临时目录'
),
waitForOutput
)
}
@@ -334,7 +311,7 @@ function closeHandle(handle: FileHandle): Promise<void> {
export async function extractModelArchive(
options: ExtractModelArchiveOptions
): Promise<ModelArchiveDescriptor> {
ensureNotAborted(options.signal)
ensureModelOperationNotAborted(options.signal)
const maximumArchiveBytes = checkedLimit(
options.maximumArchiveBytes,
'模型 ZIP 大小限制'
@@ -399,7 +376,9 @@ export async function extractModelArchive(
const destination = resolve(options.destinationDirectory)
const seenNames = new Set<string>()
const openHandles = new Set<FileHandle>()
const completions: Promise<void>[] = []
const completions: Promise<
{ ok: true } | { ok: false; error: Error }
>[] = []
const pendingWrites = new Set<Promise<void>>()
let entryCount = 0
let totalBytes = 0
@@ -442,7 +421,11 @@ export async function extractModelArchive(
throw new Error(`模型 ZIP 条目大小超出限制:${name}`)
}
const handlePromise = open(
safeChild(destination, name),
managedModelChild(
destination,
name,
'模型 ZIP 路径超出临时目录'
),
'wx'
).then((handle) => {
openHandles.add(handle)
@@ -456,7 +439,12 @@ export async function extractModelArchive(
resolveEntry = resolveEntryPromise
rejectEntry = rejectEntryPromise
})
completions.push(completion)
completions.push(
completion.then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
)
file.ondata = (error, data, final) => {
if (error) {
rejectEntry?.(fail(error))
@@ -480,10 +468,6 @@ export async function extractModelArchive(
}
written += data.byteLength
totalBytes += data.byteLength
if (name !== ARCHIVE_MANIFEST_NAME) {
completedModelBytes += data.byteLength
options.onProgress?.(completedModelBytes)
}
if (
written > entryMaximum ||
totalBytes > maximumTotalBytes
@@ -497,7 +481,12 @@ export async function extractModelArchive(
writeChain = writeChain.then(async () => {
const handle = await handlePromise
if (data.byteLength > 0) {
await handle.write(data)
await writeModelBuffer(handle, data, (persisted) => {
if (name !== ARCHIVE_MANIFEST_NAME) {
completedModelBytes += persisted.byteLength
options.onProgress?.(completedModelBytes)
}
})
}
})
const pendingWrite = writeChain
@@ -529,7 +518,7 @@ export async function extractModelArchive(
const buffer = Buffer.allocUnsafe(16 * 1024)
try {
while (true) {
ensureNotAborted(options.signal)
ensureModelOperationNotAborted(options.signal)
if (fatalError) {
throw fatalError
}
@@ -544,7 +533,13 @@ export async function extractModelArchive(
)
await Promise.all([...pendingWrites])
}
await Promise.all(completions)
const completionResults = await Promise.all(completions)
const failedCompletion = completionResults.find(
(result) => !result.ok
)
if (failedCompletion && !failedCompletion.ok) {
throw failedCompletion.error
}
if (fatalError) {
throw fatalError
}
@@ -571,7 +566,11 @@ export async function extractModelArchive(
manifest = modelArchiveManifestSchema.parse(
JSON.parse(
await readFile(
safeChild(destination, ARCHIVE_MANIFEST_NAME),
managedModelChild(
destination,
ARCHIVE_MANIFEST_NAME,
'模型 ZIP 路径超出临时目录'
),
'utf8'
)
) as unknown
@@ -597,13 +596,19 @@ export async function extractModelArchive(
throw new Error('模型 ZIP 清单与当前模型目录不匹配')
}
for (const archived of manifest.files) {
const path = safeChild(destination, archived.name)
const path = managedModelChild(
destination,
archived.name,
'模型 ZIP 路径超出临时目录'
)
const metadata = await lstat(path)
const hash = await hashModelFile(path)
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size !== archived.size ||
(await hashFile(path)) !== archived.sha256
hash.size !== archived.size ||
hash.sha256 !== archived.sha256
) {
throw new Error(`模型 ZIP 文件校验失败:${archived.name}`)
}
+3 -3
View File
@@ -1,3 +1,5 @@
import { ensureModelOperationNotAborted } from './model-package-utils'
const MAX_REDIRECTS = 3
const redirectStatuses = new Set([301, 302, 303, 307, 308])
@@ -28,9 +30,7 @@ export async function fetchModelDownloadResponse(options: {
const initialHost = url.hostname
const allowedRedirectHosts = new Set(options.redirectHosts)
for (let redirectCount = 0; ; redirectCount += 1) {
if (options.signal.aborted) {
throw new DOMException('The operation was aborted', 'AbortError')
}
ensureModelOperationNotAborted(options.signal)
const response = await options.transport(url, {
method: 'GET',
redirect: 'manual',
+154
View File
@@ -0,0 +1,154 @@
import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
readdir,
rename,
rm,
unlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
cleanupStaleModelInstallArtifacts,
writeModelBuffer
} from './model-package-utils'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('cleanupStaleModelInstallArtifacts', () => {
it('preserves active staging and names outside the manager contract', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-'))
temporaryDirectories.push(root)
const active =
'.install-active-model-00000000-0000-4000-8000-000000000001'
const stale =
'.install-owned-model-00000000-0000-4000-8000-000000000002'
const userDirectory = '.install-owned-model-backup'
await Promise.all([
mkdir(join(root, active)),
mkdir(join(root, stale)),
mkdir(join(root, userDirectory)),
mkdir(join(root, 'owned-model'))
])
await writeFile(join(root, userDirectory, 'keep.txt'), 'keep')
await writeFile(join(root, 'owned-model', 'package.bin.partial'), 'stale')
await writeFile(join(root, 'owned-model', 'notes.partial'), 'keep')
await cleanupStaleModelInstallArtifacts({
rootDirectory: root,
isModelId: (value) =>
value === 'active-model' || value === 'owned-model',
activeModelIds: new Set(['active-model']),
partialFileNames: new Set(['package.bin']),
escapeMessage: 'escaped'
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([active, userDirectory, 'owned-model'])
)
expect(await readdir(root)).not.toContain(stale)
await expect(
readFile(join(root, userDirectory, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(join(root, 'owned-model', 'notes.partial'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(
join(root, 'owned-model', 'package.bin.partial'),
'utf8'
)
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('ignores a selection partial renamed after enumeration', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-'))
temporaryDirectories.push(root)
const partialName =
'.selection.json.00000000-0000-4000-8000-000000000001.partial'
const partialPath = join(root, partialName)
const renamedPath = join(root, 'selection-completed')
await writeFile(partialPath, 'selection')
await expect(
cleanupStaleModelInstallArtifacts({
rootDirectory: root,
isModelId: () => false,
activeModelIds: new Set(),
partialFileNames: new Set(),
cleanSelectionPartials: true,
activeSelectionPartialNames: new Set(),
escapeMessage: 'escaped',
operations: {
unlinkFile: async (path) => {
await rename(path, renamedPath)
await unlink(path)
}
}
})
).resolves.toBeUndefined()
await expect(readFile(renamedPath, 'utf8')).resolves.toBe(
'selection'
)
})
})
describe('writeModelBuffer', () => {
it('retries short writes until every byte is persisted', async () => {
const persisted: number[] = []
const write = vi.fn(
async (
buffer: Uint8Array,
offset = 0,
length = buffer.byteLength - offset
) => {
const bytesWritten = Math.min(2, length)
persisted.push(
...buffer.subarray(offset, offset + bytesWritten)
)
return { bytesWritten, buffer }
}
)
const value = Uint8Array.from([1, 2, 3, 4, 5])
const hash = createHash('sha256')
const onPersisted = vi.fn((buffer: Uint8Array) => {
hash.update(buffer)
})
await expect(
writeModelBuffer({ write } as never, value, onPersisted)
).resolves.toBe(value.byteLength)
expect(persisted).toEqual([...value])
expect(write).toHaveBeenCalledTimes(3)
expect(onPersisted).toHaveBeenCalledOnce()
expect(hash.digest('hex')).toBe(
createHash('sha256').update(value).digest('hex')
)
})
it('fails closed when a write makes no progress', async () => {
await expect(
writeModelBuffer(
{
write: vi.fn(async (buffer: Uint8Array) => ({
bytesWritten: 0,
buffer
}))
} as never,
Uint8Array.from([1])
)
).rejects.toThrow('写入不完整')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { createHash, randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
open,
readdir,
rm,
unlink,
type FileHandle
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { isMissingFileError } from './settings-file-utils'
export const MODEL_PARTIAL_SUFFIX = '.partial'
export type ModelFileFingerprint = {
dev: bigint
ino: bigint
size: bigint
mode: bigint
mtimeNs: bigint
ctimeNs: bigint
isFile: boolean
isSymbolicLink: boolean
}
const uuidPattern =
'[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
const stagingNamePattern = new RegExp(
`^\\.install-(.+)-(${uuidPattern})$`,
'iu'
)
const selectionPartialPattern = new RegExp(
`^\\.selection\\.json\\.(${uuidPattern})\\.partial$`,
'iu'
)
export function ensureModelOperationNotAborted(
signal?: AbortSignal
): void {
if (signal?.aborted) {
throw new DOMException('The operation was aborted', 'AbortError')
}
}
export function managedModelChild(
parent: string,
name: string,
escapeMessage: string
): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error(escapeMessage)
}
return child
}
export async function fingerprintModelFile(
path: string
): Promise<ModelFileFingerprint> {
const status = await lstat(path, { bigint: true })
return {
dev: status.dev,
ino: status.ino,
size: status.size,
mode: status.mode,
mtimeNs: status.mtimeNs,
ctimeNs: status.ctimeNs,
isFile: status.isFile(),
isSymbolicLink: status.isSymbolicLink()
}
}
export function modelFileFingerprintMatches(
left: ModelFileFingerprint,
right: ModelFileFingerprint
): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mode === right.mode &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs &&
left.isFile === right.isFile &&
left.isSymbolicLink === right.isSymbolicLink
)
}
export async function writeModelBuffer(
handle: Pick<FileHandle, 'write'>,
buffer: Uint8Array,
onPersisted?: (buffer: Uint8Array) => void
): Promise<number> {
let offset = 0
while (offset < buffer.byteLength) {
const { bytesWritten } = await handle.write(
buffer,
offset,
buffer.byteLength - offset
)
if (
!Number.isSafeInteger(bytesWritten) ||
bytesWritten <= 0 ||
bytesWritten > buffer.byteLength - offset
) {
throw new Error('模型文件写入不完整')
}
offset += bytesWritten
}
onPersisted?.(buffer)
return offset
}
export async function hashModelFile(
path: string,
signal?: AbortSignal
): Promise<{ size: number; sha256: string }> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
let size = 0
try {
while (true) {
if (signal) {
ensureModelOperationNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
}
export function attachModelAbortSignal(
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
}
export async function createModelStagingDirectory(
rootDirectory: string,
modelId: string,
escapeMessage: string
): Promise<string> {
const directory = managedModelChild(
rootDirectory,
`.install-${modelId}-${randomUUID()}`,
escapeMessage
)
await mkdir(directory, { recursive: false })
return directory
}
export async function cleanupStaleModelInstallArtifacts(input: {
rootDirectory: string
isModelId: (value: string) => boolean
activeModelIds: ReadonlySet<string>
partialFileNames: ReadonlySet<string>
cleanSelectionPartials?: boolean
activeSelectionPartialNames?: ReadonlySet<string>
escapeMessage: string
operations?: {
unlinkFile?: (path: string) => Promise<void>
}
}): Promise<void> {
const unlinkFile = input.operations?.unlinkFile ?? unlink
const entries = await readdir(input.rootDirectory, {
withFileTypes: true
})
for (const entry of entries) {
const stagingMatch = stagingNamePattern.exec(entry.name)
if (stagingMatch) {
const modelId = stagingMatch[1]!
if (
input.isModelId(modelId) &&
!input.activeModelIds.has(modelId) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
await rm(
managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
),
{ recursive: true, force: true }
)
}
continue
}
if (
input.cleanSelectionPartials &&
selectionPartialPattern.test(entry.name) &&
!input.activeSelectionPartialNames?.has(entry.name) &&
entry.isFile() &&
!entry.isSymbolicLink()
) {
try {
await unlinkFile(
managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
)
)
} catch (error) {
if (!isMissingFileError(error)) {
throw error
}
}
continue
}
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
!input.isModelId(entry.name)
) {
continue
}
const modelDirectory = managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
)
for (const partialName of input.partialFileNames) {
const partialPath = managedModelChild(
modelDirectory,
`${partialName}${MODEL_PARTIAL_SUFFIX}`,
input.escapeMessage
)
try {
const status = await lstat(partialPath)
if (status.isFile() && !status.isSymbolicLink()) {
await unlinkFile(partialPath)
}
} catch (error) {
if (!isMissingFileError(error)) {
throw error
}
}
}
}
}
+28
View File
@@ -78,6 +78,34 @@ afterEach(async () => {
})
describe('RuntimeSettingsStore', () => {
it('restores an exact credential-bearing snapshot after a failed activation', async () => {
const { store } = await createStore()
await store.update(
settings({
apiKey: { action: 'replace', value: 'previous-key' }
})
)
const rollback = await store.captureRollback()
await store.update(
settings({
modelBaseUrl: 'https://candidate.example/v1',
modelName: 'candidate',
apiKey: { action: 'replace', value: 'candidate-key' }
})
)
await expect(rollback.restore()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
apiKeyConfigured: true
})
await expect(store.getResolvedSettings()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
apiKey: 'previous-key'
})
})
it('migrates version 17 to empty Runtime customization', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
+37
View File
@@ -235,6 +235,10 @@ const storedSettingsSchema = version17StoredSettingsSchema
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
restore(): Promise<RuntimeSettings>
}
type Version17StoredSettings = z.infer<
typeof version17StoredSettingsSchema
>
@@ -1481,6 +1485,39 @@ export class RuntimeSettingsStore {
return this.toPublicSettings(await this.load())
}
captureRollback(): Promise<RuntimeSettingsRollback> {
let result: RuntimeSettingsRollback | undefined
const operation = this.updateQueue.then(async () => {
const snapshot = structuredClone(await this.load())
result = {
publicSettings: this.toPublicSettings(snapshot),
restore: () => this.restoreSnapshot(snapshot)
}
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation.then(() => result!)
}
private restoreSnapshot(
snapshot: StoredSettings
): Promise<RuntimeSettings> {
const operation = this.updateQueue.then(async () => {
const restored = structuredClone(snapshot)
await writeJsonFileAtomically(this.filePath, restored)
this.settings = restored
this.loadWarnings = []
return this.toPublicSettings(restored)
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
async getPolicySettings(): Promise<RuntimePolicySettings> {
const settings = await this.load()
return {
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from 'vitest'
import {
areShortcutAcceleratorsEquivalent,
type GlobalShortcutSettings
} from '../shared/shortcut'
import { ShortcutSettingsService } from './shortcut-settings-service'
function createFixture(
initial: GlobalShortcutSettings = {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform = 'win32'
) {
let persisted = { ...initial }
const store = {
get: vi.fn(async () => ({ ...persisted })),
update: vi.fn(async (input: unknown) => {
persisted = input as GlobalShortcutSettings
return { ...persisted }
})
}
const registered = new Set<string>()
const conflicts = new Set<string>()
const registry = {
register: vi.fn((accelerator: string) => {
if (
conflicts.has(accelerator) ||
[...registered].some((current) =>
areShortcutAcceleratorsEquivalent(
current,
accelerator,
platform
)
)
) {
return false
}
registered.add(accelerator)
return true
}),
unregister: vi.fn((accelerator: string) => {
registered.delete(accelerator)
})
}
const service = new ShortcutSettingsService(
store,
registry,
vi.fn(),
platform
)
return {
service,
store,
registry,
registered,
conflicts,
getPersisted: () => persisted
}
}
describe('ShortcutSettingsService', () => {
it('registers the persisted shortcut at startup and reports display state', async () => {
const { service, registered } = createFixture()
await expect(service.initialize()).resolves.toMatchObject({
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
displayAccelerator: 'Ctrl+Shift+Space',
status: 'registered'
})
expect(registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it('keeps the old working registration and setting after a conflict', async () => {
const fixture = createFixture()
await fixture.service.initialize()
fixture.conflicts.add('Control+Alt+K')
await expect(
fixture.service.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
).resolves.toMatchObject({
ok: false,
error: 'conflict',
snapshot: {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
registered: true,
status: 'registered'
}
})
expect(fixture.store.update).not.toHaveBeenCalled()
expect(fixture.registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it.each([
[
'win32',
'CommandOrControl+Shift+Space',
'Control+Shift+Space'
],
[
'linux',
'CmdOrCtrl+Shift+Space',
'Control+Shift+Space'
],
[
'darwin',
'CommandOrControl+Shift+Space',
'Command+Shift+Space'
]
])(
'updates physically equivalent aliases on %s without self-conflict',
async (platform, initialAccelerator, nextAccelerator) => {
const fixture = createFixture(
{
enabled: true,
accelerator: initialAccelerator
},
platform
)
await fixture.service.initialize()
fixture.registry.register.mockClear()
await expect(
fixture.service.update({
enabled: true,
accelerator: nextAccelerator
})
).resolves.toMatchObject({
ok: true,
snapshot: {
settings: { accelerator: nextAccelerator },
registeredAccelerator: initialAccelerator,
status: 'registered'
}
})
expect(fixture.registry.register).not.toHaveBeenCalled()
expect(fixture.getPersisted().accelerator).toBe(nextAccelerator)
expect(fixture.registered).toEqual(
new Set([initialAccelerator])
)
}
)
it('rolls back a newly registered shortcut when persistence fails', async () => {
const fixture = createFixture()
await fixture.service.initialize()
fixture.store.update.mockRejectedValueOnce(new Error('disk full'))
await expect(
fixture.service.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
).resolves.toMatchObject({
ok: false,
error: 'save-failed',
snapshot: {
settings: {
accelerator: 'CommandOrControl+Shift+Space'
},
registeredAccelerator: 'CommandOrControl+Shift+Space'
}
})
expect(fixture.registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it('persists disabling before removing the working registration', async () => {
const fixture = createFixture()
await fixture.service.initialize()
await expect(
fixture.service.update({
enabled: false,
accelerator: 'CommandOrControl+Shift+Space'
})
).resolves.toMatchObject({
ok: true,
snapshot: {
registered: false,
status: 'disabled'
}
})
expect(fixture.getPersisted().enabled).toBe(false)
expect(fixture.registered).toEqual(new Set())
})
})
+173
View File
@@ -0,0 +1,173 @@
import {
areShortcutAcceleratorsEquivalent,
defaultGlobalShortcutSettings,
formatShortcutForDisplay,
globalShortcutSettingsSchema,
type GlobalShortcutRegistrationStatus,
type GlobalShortcutSettings,
type GlobalShortcutSettingsSnapshot,
type GlobalShortcutSettingsUpdateResult
} from '../shared/shortcut'
export interface GlobalShortcutRegistry {
register(accelerator: string, callback: () => void): boolean
unregister(accelerator: string): void
}
export interface ShortcutSettingsPersistence {
get(): Promise<GlobalShortcutSettings>
update(input: unknown): Promise<GlobalShortcutSettings>
}
export class ShortcutSettingsService {
private settings: GlobalShortcutSettings = {
...defaultGlobalShortcutSettings
}
private registeredAccelerator?: string
private status: GlobalShortcutRegistrationStatus = 'disabled'
private updateQueue: Promise<void> = Promise.resolve()
constructor(
private readonly store: ShortcutSettingsPersistence,
private readonly registry: GlobalShortcutRegistry,
private readonly callback: () => void,
private readonly platform: string
) {}
async initialize(): Promise<GlobalShortcutSettingsSnapshot> {
this.settings = await this.store.get()
if (!this.settings.enabled) {
this.status = 'disabled'
return this.snapshot()
}
const result = this.tryRegister(this.settings.accelerator)
if (result === 'registered') {
this.registeredAccelerator = this.settings.accelerator
}
this.status = result
return this.snapshot()
}
private tryRegister(
accelerator: string
): Extract<
GlobalShortcutRegistrationStatus,
'registered' | 'conflict' | 'failed'
> {
try {
return this.registry.register(accelerator, this.callback)
? 'registered'
: 'conflict'
} catch {
return 'failed'
}
}
snapshot(): GlobalShortcutSettingsSnapshot {
return {
settings: { ...this.settings },
defaultSettings: { ...defaultGlobalShortcutSettings },
platform: this.platform,
displayAccelerator: formatShortcutForDisplay(
this.settings.accelerator,
this.platform
),
registered: this.registeredAccelerator !== undefined,
...(this.registeredAccelerator
? { registeredAccelerator: this.registeredAccelerator }
: {}),
status: this.status
}
}
getSnapshot(): GlobalShortcutSettingsSnapshot {
return this.snapshot()
}
update(input: unknown): Promise<GlobalShortcutSettingsUpdateResult> {
const operation = this.updateQueue.then(
async (): Promise<GlobalShortcutSettingsUpdateResult> => {
const next = globalShortcutSettingsSchema.parse(input)
const previous = this.settings
const previousRegistered = this.registeredAccelerator
const previousStatus = this.status
if (!next.enabled) {
try {
await this.store.update(next)
} catch {
return {
ok: false,
error: 'save-failed',
snapshot: this.snapshot()
}
}
if (previousRegistered) {
this.registry.unregister(previousRegistered)
}
this.settings = next
this.registeredAccelerator = undefined
this.status = 'disabled'
return { ok: true, snapshot: this.snapshot() }
}
const keepsWorkingRegistration =
previousRegistered !== undefined &&
areShortcutAcceleratorsEquivalent(
previousRegistered,
next.accelerator,
this.platform
)
if (!keepsWorkingRegistration) {
const registration = this.tryRegister(next.accelerator)
if (registration !== 'registered') {
this.status = previousRegistered
? 'registered'
: registration
return {
ok: false,
error:
registration === 'conflict'
? 'conflict'
: 'registration-failed',
snapshot: this.snapshot()
}
}
}
try {
await this.store.update(next)
} catch {
if (!keepsWorkingRegistration) {
this.registry.unregister(next.accelerator)
}
this.settings = previous
this.registeredAccelerator = previousRegistered
this.status = previousRegistered
? 'registered'
: previousStatus
return {
ok: false,
error: 'save-failed',
snapshot: this.snapshot()
}
}
if (previousRegistered && !keepsWorkingRegistration) {
this.registry.unregister(previousRegistered)
}
this.settings = next
this.registeredAccelerator = keepsWorkingRegistration
? previousRegistered
: next.accelerator
this.status = 'registered'
return { ok: true, snapshot: this.snapshot() }
}
)
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}
+79
View File
@@ -0,0 +1,79 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
defaultGlobalShortcutSettings
} from '../shared/shortcut'
import { ShortcutSettingsStore } from './shortcut-settings-store'
const directories: string[] = []
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createStore(): Promise<{
filePath: string
store: ShortcutSettingsStore
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-shortcut-'))
directories.push(directory)
const filePath = join(directory, 'shortcut-settings.json')
return { filePath, store: new ShortcutSettingsStore(filePath) }
}
describe('ShortcutSettingsStore', () => {
it('preserves the legacy shortcut as the non-persisted default', async () => {
const { filePath, store } = await createStore()
await expect(store.get()).resolves.toEqual(
defaultGlobalShortcutSettings
)
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('persists a validated versioned shortcut and reloads it', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({
enabled: false,
accelerator: 'ctrl+alt+k'
})
).resolves.toEqual({
enabled: false,
accelerator: 'Control+Alt+K'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 1,
enabled: false,
accelerator: 'Control+Alt+K'
})
await expect(
new ShortcutSettingsStore(filePath).get()
).resolves.toEqual({
enabled: false,
accelerator: 'Control+Alt+K'
})
})
it('rejects invalid accelerators without replacing saved state', async () => {
const { filePath, store } = await createStore()
await store.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
const saved = await readFile(filePath, 'utf8')
await expect(
store.update({ enabled: true, accelerator: 'K' })
).rejects.toThrow()
expect(await readFile(filePath, 'utf8')).toBe(saved)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
defaultGlobalShortcutSettings,
globalShortcutSettingsSchema,
type GlobalShortcutSettings
} from '../shared/shortcut'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
const CURRENT_SETTINGS_VERSION = 1
const storedShortcutSettingsSchema = globalShortcutSettingsSchema
.extend({ version: z.literal(CURRENT_SETTINGS_VERSION) })
.strict()
type StoredShortcutSettings = z.infer<
typeof storedShortcutSettingsSchema
>
export class ShortcutSettingsStore {
private settings?: StoredShortcutSettings
private loadOperation?: Promise<StoredShortcutSettings>
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
private async readStored(): Promise<StoredShortcutSettings> {
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await isolateCorruptSettingsFile(
this.filePath,
'Shortcut settings are corrupt and could not be isolated'
)
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持快捷键设置版本 ${version},请升级应用后重试`
)
const result = storedShortcutSettingsSchema.safeParse(parsed)
if (!result.success) {
await isolateCorruptSettingsFile(
this.filePath,
'Shortcut settings are corrupt and could not be isolated'
)
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
return result.data
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Shortcut settings could not be read', {
cause: error
})
}
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
}
private async load(): Promise<StoredShortcutSettings> {
if (this.settings) {
return this.settings
}
if (!this.loadOperation) {
this.loadOperation = this.readStored()
.then((settings) => {
this.settings = settings
return settings
})
.finally(() => {
this.loadOperation = undefined
})
}
return this.loadOperation
}
async get(): Promise<GlobalShortcutSettings> {
const { enabled, accelerator } = await this.load()
return { enabled, accelerator }
}
update(input: unknown): Promise<GlobalShortcutSettings> {
const operation = this.updateQueue.then(async () => {
const settings = globalShortcutSettingsSchema.parse(input)
const stored: StoredShortcutSettings = {
version: CURRENT_SETTINGS_VERSION,
...settings
}
await writeJsonFileAtomically(this.filePath, stored)
this.settings = stored
return settings
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}
@@ -5,6 +5,8 @@ import {
readFile,
readdir,
rm,
stat,
utimes,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -249,6 +251,147 @@ describe('speech model catalog', () => {
})
describe('SpeechModelManager downloads', () => {
it('rebuilds a stale selected runtime after valid files are restored', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
const root = join(userData, 'models', 'speech')
const staleStaging =
'.install-download-test-model-00000000-0000-4000-8000-000000000001'
await mkdir(root, { recursive: true })
await Promise.all([
mkdir(join(root, staleStaging)),
writeFile(
join(root, '.selection.json'),
`${JSON.stringify({
selectedModelId: 'download-test-model'
})}\n`
)
])
const getDownloadSource = vi.fn(() => 'modelscope' as const)
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes),
getDownloadSource
})
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await manager.install('download-test-model', 'modelscope')
const selected = await manager.getSelectedRuntimeModel()
expect(selected).toMatchObject({ id: 'download-test-model' })
const modelPath = join(
root,
'download-test-model',
'model.onnx'
)
const originalModelStat = await stat(modelPath)
await writeFile(
modelPath,
Buffer.alloc(modelBytes.byteLength, 0x7f)
)
await utimes(
modelPath,
originalModelStat.atime,
originalModelStat.mtime
)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await writeFile(modelPath, modelBytes)
await utimes(
modelPath,
originalModelStat.atime,
originalModelStat.mtime
)
const rebuilt = await Promise.all([
manager.getSelectedRuntimeModel(),
manager.getSelectedRuntimeModel()
])
expect(rebuilt).toEqual([
expect.objectContaining({ id: 'download-test-model' }),
expect.objectContaining({ id: 'download-test-model' })
])
const manifestPath = join(
root,
'download-test-model',
'manifest.json'
)
const manifest = await readFile(manifestPath)
await rm(manifestPath)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await writeFile(manifestPath, manifest)
await expect(manager.getSelectedRuntimeModel()).resolves.toMatchObject({
id: 'download-test-model'
})
expect(await readdir(root)).toContain(staleStaging)
expect(getDownloadSource).not.toHaveBeenCalled()
await writeFile(
join(root, '.selection.json'),
'{"selectedModelId":null}\n'
)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
})
it('preserves an active selection partial during concurrent snapshot cleanup', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
let markWriteStarted: (() => void) | undefined
let releaseWrite: (() => void) | undefined
const writeStarted = new Promise<void>((resolveStarted) => {
markWriteStarted = resolveStarted
})
const writeGate = new Promise<void>((resolveWrite) => {
releaseWrite = resolveWrite
})
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes),
selectionFileOperations: {
writeFile: async (path, data, options) => {
await writeFile(path, data, options)
markWriteStarted?.()
await writeGate
}
}
})
await manager.install('download-test-model')
const selecting = manager.select('download-test-model')
await writeStarted
const root = join(userData, 'models', 'speech')
const activePartial = (await readdir(root)).find(
(name) =>
name.startsWith('.selection.json.') &&
name.endsWith('.partial')
)
expect(activePartial).toBeDefined()
await manager.snapshot()
expect(await readdir(root)).toContain(activePartial)
releaseWrite?.()
await selecting
await expect(manager.snapshot()).resolves.toMatchObject({
selectedModelId: 'download-test-model'
})
expect(await readdir(root)).not.toContain(activePartial)
})
it('downloads to partial files, verifies hashes, and atomically installs', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
@@ -330,6 +473,75 @@ describe('SpeechModelManager downloads', () => {
})
})
it('cleans only manager-owned stale staging and partial artifacts', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes)
})
await manager.install('download-test-model')
const root = join(userData, 'models', 'speech')
const modelDirectory = join(root, 'download-test-model')
const staleStaging =
'.install-download-test-model-00000000-0000-4000-8000-000000000001'
const unrelatedStaging = '.install-download-test-model-user-backup'
const selectionPartial =
'.selection.json.00000000-0000-4000-8000-000000000002.partial'
await mkdir(join(root, staleStaging))
await writeFile(join(root, staleStaging, 'model.onnx.partial'), 'stale')
await mkdir(join(root, unrelatedStaging))
await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep')
await writeFile(
join(modelDirectory, 'model.onnx.partial'),
'interrupted'
)
await writeFile(join(modelDirectory, 'notes.partial'), 'keep')
await writeFile(join(root, selectionPartial), 'interrupted')
await writeFile(join(root, 'user.partial'), 'keep')
await expect(manager.snapshot()).resolves.toMatchObject({
installed: [expect.objectContaining({ id: 'download-test-model' })]
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([
'download-test-model',
unrelatedStaging,
'user.partial'
])
)
expect(await readdir(root)).not.toEqual(
expect.arrayContaining([staleStaging, selectionPartial])
)
expect(await readdir(modelDirectory)).toEqual(
expect.arrayContaining([
'manifest.json',
'model.onnx',
'tokens.txt',
'notes.partial'
])
)
expect(await readdir(modelDirectory)).not.toContain(
'model.onnx.partial'
)
await expect(
readFile(join(modelDirectory, 'model.onnx'))
).resolves.toEqual(Buffer.from(modelBytes))
await expect(
readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
})
it('freezes the operation source when the global setting changes', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
+304 -113
View File
@@ -11,7 +11,7 @@ import {
stat,
writeFile
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import { z } from 'zod'
import {
installedSpeechModelSchema,
@@ -39,11 +39,24 @@ import {
extractModelArchive
} from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
fingerprintModelFile,
hashModelFile,
managedModelChild,
modelFileFingerprintMatches,
type ModelFileFingerprint,
writeModelBuffer
} from '../model-package-utils'
import { isMissingFileError } from '../settings-file-utils'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
@@ -61,6 +74,17 @@ type ActiveOperation = {
progress: SpeechModelOperation
}
type SpeechSelectionFileOperations = {
writeFile: typeof writeFile
rename: typeof rename
}
type CachedSelectedSpeechRuntimeModel = {
model: SelectedSpeechRuntimeModel
manifestFingerprint: ModelFileFingerprint
fileFingerprints: Map<string, ModelFileFingerprint>
}
export type SpeechModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
@@ -69,6 +93,7 @@ export type SpeechModelManagerOptions = {
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
selectionFileOperations?: Partial<SpeechSelectionFileOperations>
}
export type SelectedSpeechRuntimeModel = {
@@ -101,16 +126,6 @@ function toCatalogView(entry: SpeechModelCatalogEntry) {
})
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError()
}
}
function validateMaximumBytes(value: number | undefined): number {
const maximum = value ?? DEFAULT_MAX_FILE_BYTES
if (
@@ -124,40 +139,7 @@ function validateMaximumBytes(value: number | undefined): number {
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('模型路径超出受管目录')
}
return child
}
async function hashFile(
path: string,
signal?: AbortSignal
): Promise<{
size: number
sha256: string
}> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
const buffer = Buffer.allocUnsafe(64 * 1024)
try {
while (true) {
if (signal) {
ensureNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
return managedModelChild(parent, name, '模型路径超出受管目录')
}
export class SpeechModelManager {
@@ -170,7 +152,13 @@ export class SpeechModelManager {
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly selectionFileOperations: SpeechSelectionFileOperations
private readonly operations = new Map<string, ActiveOperation>()
private readonly activeSelectionPartialNames = new Set<string>()
private selectedRuntimeModel?: Promise<
CachedSelectedSpeechRuntimeModel | undefined
>
private selectedRuntimeGeneration = 0
constructor(options: SpeechModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
@@ -192,10 +180,16 @@ export class SpeechModelManager {
}
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
this.selectionFileOperations = {
writeFile,
rename,
...options.selectionFileOperations
}
}
async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [installed, selected, selectedDownloadSource] =
await Promise.all([
this.readInstalled(),
@@ -236,25 +230,52 @@ export class SpeechModelManager {
async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined
> {
const snapshot = await this.snapshot()
if (!snapshot.selectedModelId) {
const selectedModelId = await this.readSelection()
if (!selectedModelId) {
this.invalidateSelectedRuntimeModel()
return undefined
}
const catalogEntry = this.catalog.find(
(entry) => entry.id === snapshot.selectedModelId
)
const installed = snapshot.installed.find(
(entry) => entry.id === snapshot.selectedModelId
)
if (!catalogEntry || !installed) {
return undefined
const cachedPromise = this.selectedRuntimeModel
if (cachedPromise) {
const cached = await cachedPromise
if (
cached?.model.id === selectedModelId &&
(await this.selectedRuntimeFingerprintsMatch(cached))
) {
return this.cloneSelectedRuntimeModel(cached.model)
}
if (this.selectedRuntimeModel === cachedPromise) {
this.invalidateSelectedRuntimeModel()
}
}
return {
id: installed.id,
family: catalogEntry.family,
directory: this.modelDirectory(installed.id),
files: installed.files.map((file) => ({ ...file }))
const selected = await this.getOrCreateSelectedRuntimeModel(
selectedModelId
)
return selected
? this.cloneSelectedRuntimeModel(selected.model)
: undefined
}
private getOrCreateSelectedRuntimeModel(
selectedModelId: string
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
const current = this.selectedRuntimeModel
if (current) {
return current
}
const generation = this.selectedRuntimeGeneration
const resolution = this.resolveSelectedRuntimeModel(
selectedModelId,
generation
)
const tracked = resolution.catch((error) => {
if (this.selectedRuntimeModel === tracked) {
this.selectedRuntimeModel = undefined
}
throw error
})
this.selectedRuntimeModel = tracked
return tracked
}
async install(
@@ -290,7 +311,7 @@ export class SpeechModelManager {
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
const destination = safeChild(stagingDirectory, file.name)
@@ -309,12 +330,13 @@ export class SpeechModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
detachExternalAbort()
@@ -338,6 +360,7 @@ export class SpeechModelManager {
async remove(modelId: string): Promise<void> {
speechModelIdSchema.parse(modelId)
this.cancel(modelId)
this.invalidateSelectedRuntimeModel()
await this.ensureRoot()
const target = this.modelDirectory(modelId)
await rm(target, { recursive: true, force: true })
@@ -356,6 +379,7 @@ export class SpeechModelManager {
}
}
await this.writeSelection(modelId)
this.invalidateSelectedRuntimeModel()
}
async registerLocalDirectory(
@@ -382,12 +406,12 @@ export class SpeechModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
await copyFile(sourceFile, destination)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
const copied = await stat(destination)
if (copied.size > this.maxFileBytes) {
throw new RangeError(`模型文件过大:${file.name}`)
@@ -404,12 +428,13 @@ export class SpeechModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
detachExternalAbort()
@@ -542,9 +567,10 @@ export class SpeechModelManager {
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
this.operations.delete(entry.id)
@@ -558,6 +584,163 @@ export class SpeechModelManager {
await mkdir(this.rootDirectory, { recursive: true })
}
private invalidateSelectedRuntimeModel(): void {
this.selectedRuntimeGeneration += 1
this.selectedRuntimeModel = undefined
}
private async resolveSelectedRuntimeModel(
selectedModelId: string,
generation: number
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
await this.ensureRoot()
const catalogEntry = this.catalog.find(
(entry) => entry.id === selectedModelId
)
if (!catalogEntry) {
return undefined
}
try {
const directory = this.modelDirectory(selectedModelId)
const manifestPath = safeChild(directory, MANIFEST_FILE_NAME)
const manifestFingerprintBefore = await fingerprintModelFile(
manifestPath
)
if (
!manifestFingerprintBefore.isFile ||
manifestFingerprintBefore.isSymbolicLink
) {
return undefined
}
const installed = installedSpeechModelSchema.parse(
JSON.parse(
await readFile(manifestPath, 'utf8')
) as unknown
)
const manifestFingerprint = await fingerprintModelFile(manifestPath)
if (
!modelFileFingerprintMatches(
manifestFingerprintBefore,
manifestFingerprint
)
) {
return undefined
}
if (installed.id !== selectedModelId) {
return undefined
}
if (
installed.files.length !== catalogEntry.files.length ||
catalogEntry.files.some((expected) => {
const recorded = installed.files.find(
(file) =>
file.name === expected.name &&
file.role === expected.role
)
return (
!recorded ||
recorded.size !== expected.size ||
recorded.sha256 !== expected.sha256
)
})
) {
return undefined
}
const fileFingerprints = new Map<string, ModelFileFingerprint>()
for (const file of installed.files) {
const path = safeChild(directory, file.name)
const fingerprintBefore = await fingerprintModelFile(path)
if (
!fingerprintBefore.isFile ||
fingerprintBefore.isSymbolicLink ||
fingerprintBefore.size !== BigInt(file.size)
) {
return undefined
}
const actual = await hashModelFile(path)
const fingerprint = await fingerprintModelFile(path)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256 ||
!modelFileFingerprintMatches(
fingerprintBefore,
fingerprint
)
) {
return undefined
}
fileFingerprints.set(file.name, fingerprint)
}
if (
generation !== this.selectedRuntimeGeneration ||
(await this.readSelection()) !== selectedModelId
) {
return undefined
}
return {
model: {
id: installed.id,
family: catalogEntry.family,
directory,
files: installed.files.map((file) => ({ ...file }))
},
manifestFingerprint,
fileFingerprints
}
} catch {
return undefined
}
}
private cloneSelectedRuntimeModel(
model: SelectedSpeechRuntimeModel
): SelectedSpeechRuntimeModel {
return {
...model,
files: model.files.map((file) => ({ ...file }))
}
}
private async selectedRuntimeFingerprintsMatch(
cached: CachedSelectedSpeechRuntimeModel
): Promise<boolean> {
try {
const manifestFingerprint = await fingerprintModelFile(
safeChild(cached.model.directory, MANIFEST_FILE_NAME)
)
if (
!manifestFingerprint.isFile ||
manifestFingerprint.isSymbolicLink ||
!modelFileFingerprintMatches(
manifestFingerprint,
cached.manifestFingerprint
)
) {
return false
}
for (const file of cached.model.files) {
const expected = cached.fileFingerprints.get(file.name)
if (!expected) {
return false
}
const actual = await fingerprintModelFile(
safeChild(cached.model.directory, file.name)
)
if (
!actual.isFile ||
actual.isSymbolicLink ||
actual.size !== BigInt(file.size) ||
!modelFileFingerprintMatches(actual, expected)
) {
return false
}
}
return true
} catch {
return false
}
}
private modelDirectory(modelId: string): string {
const parsedId = speechModelIdSchema.parse(modelId)
return safeChild(this.rootDirectory, parsedId)
@@ -601,16 +784,7 @@ export class SpeechModelManager {
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
return attachModelAbortSignal(signal, controller)
}
private async assertNotInstalled(modelId: string): Promise<void> {
@@ -618,11 +792,7 @@ export class SpeechModelManager {
await lstat(this.modelDirectory(modelId))
throw new Error('语音模型已安装')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return
}
throw error
@@ -630,12 +800,11 @@ export class SpeechModelManager {
}
private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild(
return createModelStagingDirectory(
this.rootDirectory,
`.install-${modelId}-${randomUUID()}`
modelId,
'模型路径超出受管目录'
)
await mkdir(directory, { recursive: false })
return directory
}
private async downloadFile(
@@ -676,29 +845,34 @@ export class SpeechModelManager {
}
}
const partialPath = `${destination}${PARTIAL_SUFFIX}`
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > file.size ||
written > this.maxFileBytes
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`模型文件过大:${file.name}`)
}
await handle.write(result.value)
hash.update(result.value)
operation.progress.completedBytes += result.value.byteLength
const persistedBytes = await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
}
} catch (error) {
await reader.cancel().catch(() => undefined)
@@ -728,7 +902,7 @@ export class SpeechModelManager {
visited: 0
})
for (const expectedFile of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const sourceFile = safeChild(sourceDirectory, expectedFile.name)
const sourceFileInfo = await lstat(sourceFile)
if (
@@ -745,7 +919,7 @@ export class SpeechModelManager {
}
if (
sourceFileInfo.size !== expectedFile.size ||
(await hashFile(sourceFile, signal)).sha256 !==
(await hashModelFile(sourceFile, signal)).sha256 !==
expectedFile.sha256
) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
@@ -760,7 +934,7 @@ export class SpeechModelManager {
): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
counter.visited += 1
if (counter.visited > 4_096) {
throw new Error('本地模型目录包含过多条目')
@@ -830,8 +1004,8 @@ export class SpeechModelManager {
): Promise<InstalledSpeechModel> {
const files = []
for (const file of entry.files) {
ensureNotAborted(signal)
const metadata = await hashFile(
ensureModelOperationNotAborted(signal)
const metadata = await hashModelFile(
safeChild(stagingDirectory, file.name),
signal
)
@@ -899,11 +1073,7 @@ export class SpeechModelManager {
)
return value.selectedModelId
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return null
}
return null
@@ -913,24 +1083,45 @@ export class SpeechModelManager {
private async writeSelection(modelId: string | null): Promise<void> {
await this.ensureRoot()
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
const partialName =
`${SELECTION_FILE_NAME}.${randomUUID()}${MODEL_PARTIAL_SUFFIX}`
const partial = safeChild(
this.rootDirectory,
`${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}`
)
await writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
partialName
)
this.activeSelectionPartialNames.add(partialName)
try {
await rename(partial, target)
await this.selectionFileOperations.writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
await this.selectionFileOperations.rename(partial, target)
} catch (error) {
await rm(partial, { force: true })
throw error
} finally {
this.activeSelectionPartialNames.delete(partialName)
}
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) => speechModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
cleanSelectionPartials: true,
activeSelectionPartialNames: this.activeSelectionPartialNames,
escapeMessage: '模型路径超出受管目录'
})
}
}
export function createSpeechModelManager(
+193 -3
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { runStartupPrerequisites } from './startup-prerequisites'
import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
runStartupPrerequisites,
StartupPrerequisiteError
} from './startup-prerequisites'
function deferred<T = void>(): {
promise: Promise<T>
@@ -83,7 +88,11 @@ describe('runStartupPrerequisites', () => {
expect(rejected).not.toHaveBeenCalled()
configuredRuntime.resolve({ id: 'unused' })
await expect(result).rejects.toBe(assistantError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'assistant-database',
cause: assistantError
})
expect(rejected).toHaveBeenCalledOnce()
})
@@ -108,7 +117,188 @@ describe('runStartupPrerequisites', () => {
deepSeekHome.resolve()
knowledgeAndGateway.resolve()
await expect(result).rejects.toBe(runtimeError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'runtime',
cause: runtimeError
})
expect(rejected).toHaveBeenCalledOnce()
})
it('collects simultaneous async failures in deterministic stage order', async () => {
const runtimeHomeError = new TypeError('runtime home failed')
const knowledgeError = new RangeError('knowledge failed')
const runtimeError = new SyntaxError('runtime failed')
const deepSeekHome = deferred()
const knowledgeAndGateway = deferred()
const configuredRuntime = deferred<{ id: string }>()
const result = runStartupPrerequisites({
prepareDeepSeekHome: () => deepSeekHome.promise,
initializeKnowledgeAndGateway: () =>
knowledgeAndGateway.promise,
hydrateConfiguredRuntime: () => configuredRuntime.promise,
initializeAssistant: () => undefined
})
configuredRuntime.reject(runtimeError)
knowledgeAndGateway.reject(knowledgeError)
deepSeekHome.reject(runtimeHomeError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'runtime-home',
stages: ['runtime-home', 'knowledge', 'runtime'],
cause: runtimeHomeError
})
})
it('preserves assistant initialization as the primary failure', async () => {
const assistantError = new Error('assistant failed')
const runtimeHomeError = new Error('runtime home failed')
await expect(
runStartupPrerequisites({
prepareDeepSeekHome: () => Promise.reject(runtimeHomeError),
initializeKnowledgeAndGateway: () =>
Promise.reject(new Error('knowledge failed')),
hydrateConfiguredRuntime: () =>
Promise.reject(new Error('runtime failed')),
initializeAssistant: () => {
throw assistantError
}
})
).rejects.toMatchObject({
stage: 'assistant-database',
stages: [
'assistant-database',
'runtime-home',
'knowledge',
'runtime'
],
cause: assistantError
})
})
it.each([
['runtime-home', 'prepareDeepSeekHome'],
['knowledge', 'initializeKnowledgeAndGateway'],
['runtime', 'hydrateConfiguredRuntime']
] as const)(
'identifies a failed %s startup branch',
async (stage, operation) => {
const failure = new Error(`${stage} failed`)
const dependencies = {
prepareDeepSeekHome: () => Promise.resolve(),
initializeKnowledgeAndGateway: () => Promise.resolve(),
hydrateConfiguredRuntime: () => Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
}
dependencies[operation] = () => Promise.reject(failure) as never
const result = runStartupPrerequisites(dependencies)
await expect(result).rejects.toEqual(
expect.objectContaining<Partial<StartupPrerequisiteError>>({
name: 'StartupPrerequisiteError',
stage,
cause: failure
})
)
}
)
it.each([
['runtime-home', 'prepareDeepSeekHome'],
['knowledge', 'initializeKnowledgeAndGateway'],
['runtime', 'hydrateConfiguredRuntime']
] as const)(
'observes a synchronous throw from the %s promise dependency',
async (stage, operation) => {
const failure = new Error(`${stage} synchronous failure`)
const dependencies = {
prepareDeepSeekHome: () => Promise.resolve(),
initializeKnowledgeAndGateway: () => Promise.resolve(),
hydrateConfiguredRuntime: () =>
Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
}
dependencies[operation] = (() => {
throw failure
}) as never
await expect(
runStartupPrerequisites(dependencies)
).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage,
stages: [stage],
cause: failure
})
}
)
})
describe('startup failure reporting', () => {
it('keeps secret-bearing causes out of user-facing formatting', () => {
const secret = 'provider-key=sk-secret-value'
const failure = new Error(secret)
const error = new StartupPrerequisiteError(
'runtime',
failure,
['runtime']
)
const message = formatStartupFailureMessage(error)
expect(message).toContain('阶段:runtime')
expect(message).not.toContain(secret)
expect(Object.isFrozen(error.stages)).toBe(true)
})
it('formats all prerequisite stages and logs only bounded metadata', async () => {
const secret = 'provider-key=sk-secret-value'
let startupError: unknown
try {
await runStartupPrerequisites({
prepareDeepSeekHome: () =>
Promise.reject(new TypeError(secret)),
initializeKnowledgeAndGateway: () =>
Promise.reject(new Error('another secret')),
hydrateConfiguredRuntime: () =>
Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
})
} catch (error) {
startupError = error
}
const message = formatStartupFailureMessage(startupError)
const diagnostic = createStartupFailureDiagnostic(startupError)
expect(message).toContain('阶段:runtime-home, knowledge')
expect(message).not.toContain(secret)
expect(diagnostic).toEqual({
stages: ['runtime-home', 'knowledge'],
errorName: 'StartupPrerequisiteError',
causeName: 'TypeError'
})
expect(JSON.stringify(diagnostic)).not.toContain(secret)
expect(Object.isFrozen(diagnostic)).toBe(true)
expect(Object.isFrozen(diagnostic.stages)).toBe(true)
})
it('maps generic startup errors to the closed application stage', () => {
const secret = 'provider-key=sk-secret-value'
const error = new Error(secret)
expect(formatStartupFailureMessage(error)).toContain(
'阶段:application'
)
expect(createStartupFailureDiagnostic(error)).toEqual({
stages: ['application'],
errorName: 'Error'
})
expect(formatStartupFailureMessage(error)).not.toContain(secret)
})
})
+131 -5
View File
@@ -5,6 +5,101 @@ export type StartupPrerequisiteDependencies<ConfiguredRuntime> = {
initializeAssistant: () => void
}
export type StartupPrerequisiteStage =
| 'runtime-home'
| 'knowledge'
| 'runtime'
| 'assistant-database'
export type StartupFailureStage =
| StartupPrerequisiteStage
| 'application'
export type StartupFailureDiagnostic = Readonly<{
stages: readonly StartupFailureStage[]
errorName: string
causeName?: string
}>
export class StartupPrerequisiteError extends Error {
readonly stage: StartupPrerequisiteStage
readonly stages: readonly StartupPrerequisiteStage[]
constructor(
stage: StartupPrerequisiteStage,
cause: unknown,
stages: readonly StartupPrerequisiteStage[] = [stage]
) {
super(`Startup prerequisite failed: ${stage}`, { cause })
this.name = 'StartupPrerequisiteError'
this.stage = stage
this.stages = Object.freeze([...stages])
}
}
const applicationFailureStages = Object.freeze([
'application'
] satisfies StartupFailureStage[])
const safeErrorNames = new Set([
'AbortError',
'AggregateError',
'Error',
'EvalError',
'RangeError',
'ReferenceError',
'SyntaxError',
'TimeoutError',
'TypeError',
'URIError'
])
function safeErrorName(error: unknown): string {
if (!(error instanceof Error)) {
return 'NonError'
}
let name: unknown
try {
name = error.name
} catch {
return 'Error'
}
return typeof name === 'string' && safeErrorNames.has(name)
? name
: 'Error'
}
export function getStartupFailureStages(
error: unknown
): readonly StartupFailureStage[] {
return error instanceof StartupPrerequisiteError
? error.stages
: applicationFailureStages
}
export function formatStartupFailureMessage(error: unknown): string {
const stages = getStartupFailureStages(error)
return `启动初始化未完成(阶段:${stages.join(', ')})。请重启应用;若问题持续,请记录阶段标识,并在备份数据后排查应用数据或 Runtime 配置。`
}
export function createStartupFailureDiagnostic(
error: unknown
): StartupFailureDiagnostic {
const stages = Object.freeze([...getStartupFailureStages(error)])
if (error instanceof StartupPrerequisiteError) {
return Object.freeze({
stages,
errorName: 'StartupPrerequisiteError',
causeName: safeErrorName(error.cause)
})
}
return Object.freeze({
stages,
errorName: safeErrorName(error)
})
}
function startObserved<T>(operation: () => Promise<T>): Promise<T> {
let started: Promise<T>
try {
@@ -45,17 +140,48 @@ export async function runStartupPrerequisites<ConfiguredRuntime>(
configuredRuntimeReady
] as const)
const failures: Array<
Readonly<{
stage: StartupPrerequisiteStage
cause: unknown
}>
> = []
if (assistantInitializationFailed) {
throw assistantInitializationError
failures.push({
stage: 'assistant-database',
cause: assistantInitializationError
})
}
if (deepSeekHome.status === 'rejected') {
throw deepSeekHome.reason
failures.push({
stage: 'runtime-home',
cause: deepSeekHome.reason
})
}
if (knowledgeAndGateway.status === 'rejected') {
throw knowledgeAndGateway.reason
failures.push({
stage: 'knowledge',
cause: knowledgeAndGateway.reason
})
}
if (configuredRuntime.status === 'rejected') {
throw configuredRuntime.reason
failures.push({
stage: 'runtime',
cause: configuredRuntime.reason
})
}
return configuredRuntime.value
const primaryFailure = failures[0]
if (primaryFailure) {
throw new StartupPrerequisiteError(
primaryFailure.stage,
primaryFailure.cause,
failures.map(({ stage }) => stage)
)
}
if (configuredRuntime.status === 'fulfilled') {
return configuredRuntime.value
}
throw new Error('Unreachable startup prerequisite state')
}