feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApplicationSettingsStore,
|
||||
applicationSettingsSchema,
|
||||
@@ -44,6 +44,55 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ApplicationSettingsStore', () => {
|
||||
it('keeps the confirmed snapshot when an atomic save fails', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const confirmed = await store.get()
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
await mkdir(filePath)
|
||||
await expect(store.update({ localInferenceEnabled: false })).rejects.toThrow()
|
||||
expect(await store.get()).toEqual(confirmed)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key]
|
||||
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
|
||||
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
|
||||
expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
|
||||
})
|
||||
it('migrates released settings without replacing explicit false values or comment choices', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
|
||||
for (const key of ['applicationNavigation', 'localInferenceEnabled']) delete legacy[key]
|
||||
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
|
||||
const migrated = await store.get()
|
||||
expect(migrated).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
|
||||
await store.update({ localInferenceEnabled: false })
|
||||
await store.update({ applicationNavigation: { ...migrated.applicationNavigation, order: [...migrated.applicationNavigation.order].reverse(), pinned: { ...migrated.applicationNavigation.pinned, 'local-inference': false } } })
|
||||
const reloaded = await createApplicationSettingsStore(filePath).get()
|
||||
expect(reloaded).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
|
||||
expect(reloaded.applicationNavigation.order).toEqual([...migrated.applicationNavigation.order].reverse())
|
||||
expect(reloaded.applicationNavigation.pinned['local-inference']).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects incomplete, duplicate, unknown order and pin values without applying defaults to patches', () => {
|
||||
const navigation = defaultApplicationSettings.applicationNavigation
|
||||
for (const invalid of [
|
||||
{ ...navigation, order: navigation.order.slice(1) },
|
||||
{ ...navigation, order: ['magic-notes', 'magic-notes'] },
|
||||
{ ...navigation, order: ['unknown', 'local-inference'] },
|
||||
{ ...navigation, order: ['knowledge', 'local-inference'] },
|
||||
{ ...navigation, order: ['heartbeat', 'local-inference'] },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, heartbeat: true } },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, knowledge: true } },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, extra: true } },
|
||||
{ ...navigation, pinned: { knowledge: true } }
|
||||
]) expect(applicationSettingsUpdateSchema.safeParse({ applicationNavigation: invalid }).success).toBe(false)
|
||||
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
|
||||
})
|
||||
it('returns defaults without creating a settings file', async () => {
|
||||
const { directory, store } = await createStore()
|
||||
|
||||
@@ -61,6 +110,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNotesShowIncompleteTodoCount: false
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -73,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -85,7 +136,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -119,7 +171,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
remoteProjectsEnabled: true
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
version: 12,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
...defaultApplicationSettings,
|
||||
remoteProjectsEnabled: true
|
||||
@@ -138,6 +190,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(
|
||||
createApplicationSettingsStore(filePath).get()
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -152,7 +205,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
|
||||
it.each([1, 2])(
|
||||
'loads version %s settings missing the field with Magic Notes disabled',
|
||||
'loads version %s settings missing the field with Magic Notes enabled',
|
||||
async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(
|
||||
@@ -165,13 +218,14 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -192,6 +246,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -219,6 +274,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -252,7 +308,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||
).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -283,6 +340,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -311,6 +369,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -323,8 +382,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'structured'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSix,
|
||||
version: 11,
|
||||
version: 12,
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -334,8 +394,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await store.update({ modelDownloadSource: 'hugging-face' })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSix,
|
||||
version: 11,
|
||||
version: 12,
|
||||
modelDownloadSource: 'hugging-face',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -359,6 +420,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -371,8 +433,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSeven,
|
||||
version: 11,
|
||||
version: 12,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -398,6 +461,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
const migratedSettings = await store.get()
|
||||
expect(migratedSettings).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'hugging-face',
|
||||
@@ -411,8 +475,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionEight,
|
||||
version: 11,
|
||||
version: 12,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false
|
||||
@@ -420,8 +485,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await store.update({ checkUpdatesOnStartup: true })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionEight,
|
||||
version: 11,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: true,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -454,6 +520,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'hugging-face',
|
||||
@@ -476,8 +543,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
|
||||
expect(persisted).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionNine,
|
||||
version: 11,
|
||||
version: 12,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
localToolEnvironment: {
|
||||
node: {
|
||||
@@ -538,6 +606,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -596,7 +665,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...versionTenSettings,
|
||||
version: 11,
|
||||
version: 12,
|
||||
conversationHtmlRenderingEnabled: true
|
||||
})
|
||||
})
|
||||
@@ -606,7 +675,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: 'false'
|
||||
}),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
@@ -685,6 +754,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
])
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -697,7 +767,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -727,6 +798,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../shared/application-settings-contracts'
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ export {
|
||||
} from '../shared/application-settings-contracts'
|
||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 11
|
||||
const CURRENT_SETTINGS_VERSION = 12
|
||||
|
||||
const legacyStoredApplicationSettingsSchema = z
|
||||
.object({
|
||||
@@ -148,6 +149,8 @@ const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
|
||||
|
||||
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
|
||||
|
||||
export type LegacyLocalToolEnvironmentPaths = {
|
||||
@@ -234,7 +237,9 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -245,18 +250,26 @@ export class ApplicationSettingsStore {
|
||||
private settingsLoad?: Promise<StoredApplicationSettings>
|
||||
private warnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
private readonly listeners = new Set<(settings: ApplicationSettings) => void>()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
|
||||
) {}
|
||||
|
||||
onChanged(listener: (settings: ApplicationSettings) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
private async migrateLegacy(
|
||||
settings: Omit<
|
||||
StoredApplicationSettings,
|
||||
| 'conversationHtmlRenderingEnabled'
|
||||
| 'localToolEnvironment'
|
||||
| 'version'
|
||||
| 'applicationNavigation'
|
||||
| 'localInferenceEnabled'
|
||||
> & {
|
||||
conversationHtmlRenderingEnabled?: boolean
|
||||
version: number
|
||||
@@ -271,6 +284,7 @@ export class ApplicationSettingsStore {
|
||||
? { source: 'custom', executablePath }
|
||||
: { source: 'managed' }
|
||||
const next: StoredApplicationSettings = {
|
||||
...defaultApplicationSettings,
|
||||
...settings,
|
||||
conversationHtmlRenderingEnabled:
|
||||
settings.conversationHtmlRenderingEnabled ?? true,
|
||||
@@ -328,6 +342,12 @@ export class ApplicationSettingsStore {
|
||||
)
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionElevenResult.success) {
|
||||
const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION }
|
||||
await this.persist(next)
|
||||
return next
|
||||
}
|
||||
const versionTenResult =
|
||||
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionTenResult.success) {
|
||||
@@ -437,7 +457,7 @@ export class ApplicationSettingsStore {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -482,6 +502,8 @@ export class ApplicationSettingsStore {
|
||||
conversationHtmlRenderingEnabled:
|
||||
stored.conversationHtmlRenderingEnabled,
|
||||
remoteProjectsEnabled: stored.remoteProjectsEnabled,
|
||||
applicationNavigation: stored.applicationNavigation,
|
||||
localInferenceEnabled: stored.localInferenceEnabled,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
stored.magicNotesShowIncompleteTodoCount,
|
||||
@@ -511,7 +533,7 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
await this.persist(next)
|
||||
this.warnings = []
|
||||
return {
|
||||
const settings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
updateSource: next.updateSource,
|
||||
modelDownloadSource: next.modelDownloadSource,
|
||||
@@ -519,12 +541,16 @@ export class ApplicationSettingsStore {
|
||||
conversationHtmlRenderingEnabled:
|
||||
next.conversationHtmlRenderingEnabled,
|
||||
remoteProjectsEnabled: next.remoteProjectsEnabled,
|
||||
applicationNavigation: next.applicationNavigation,
|
||||
localInferenceEnabled: next.localInferenceEnabled,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
next.magicNotesShowIncompleteTodoCount,
|
||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: next.magicNoteCommentFormat
|
||||
}
|
||||
for (const listener of this.listeners) listener(settings)
|
||||
return settings
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
assistantIdSchema,
|
||||
executionStatsInputSchema,
|
||||
conversationAnsweredQuestionSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
@@ -26,6 +27,8 @@ import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
ExecutionStats,
|
||||
ExecutionStatsInput,
|
||||
ActivityHistorySnapshot,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
@@ -4317,6 +4320,230 @@ export class AssistantDatabase {
|
||||
return rows.map(toTask)
|
||||
}
|
||||
|
||||
getExecutionStats(
|
||||
input: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string> = new Set()
|
||||
): ExecutionStats {
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
const result: ExecutionStats = {
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
|
||||
}
|
||||
const database = this.requireDatabase()
|
||||
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
|
||||
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
|
||||
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
|
||||
const observed = new Set<string>()
|
||||
const coverage = new Map<string, number>()
|
||||
const bucket = (conversationId: string | null, owner: string | null): string =>
|
||||
JSON.stringify([conversationId, owner])
|
||||
const add = (id: string | null, durationMs: number, incomplete: number): void => {
|
||||
if (!id || !('projectId' in scope)) return
|
||||
const entry = durations.get(id) ?? { id, durationMs: 0, incompleteRequestCount: 0 }
|
||||
entry.durationMs += durationMs
|
||||
entry.incompleteRequestCount += incomplete
|
||||
durations.set(id, entry)
|
||||
}
|
||||
// Materialize task-level evidence once. Stream/tool payloads are never parsed;
|
||||
// task_events_task_idx supplies request membership and boundary seeks.
|
||||
const rows = database.prepare(`
|
||||
WITH scoped AS MATERIALIZED (
|
||||
SELECT t.id, t.conversation_id, t.visible, t.started_at, t.remote_recoverable,
|
||||
COALESCE(parent.id, retained.id) AS schedule_task_id,
|
||||
space.kind = 'ssh' AS ssh_project,
|
||||
EXISTS (SELECT 1 FROM messages m
|
||||
WHERE m.conversation_id = t.conversation_id AND m.request_id = t.id) AS linked,
|
||||
EXISTS (SELECT 1 FROM task_events proof WHERE proof.task_id = t.id
|
||||
AND proof.kind IN ('text', 'reasoning', 'tool', 'done', 'error',
|
||||
'subagent', 'generated-image', 'question')) AS reply_event,
|
||||
EXISTS (SELECT 1 FROM task_events remote WHERE remote.task_id = t.id
|
||||
AND remote.remote_operation_id IS NOT NULL) AS remote_event,
|
||||
(SELECT tail.created_at FROM task_events tail WHERE tail.task_id = t.id
|
||||
ORDER BY tail.id DESC LIMIT 1) AS last_at
|
||||
FROM tasks t
|
||||
LEFT JOIN schedule_runs sr ON sr.id = t.id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = t.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
LEFT JOIN project_execution_spaces space ON space.project_id = t.project_id
|
||||
WHERE t.${scopeColumn} = ?
|
||||
AND t.schedule_id IS NULL AND t.origin != 'subagent'
|
||||
AND NOT (t.origin = 'schedule' AND t.visible = 1)
|
||||
)
|
||||
SELECT scoped.*, e.kind, e.created_at,
|
||||
CASE WHEN e.kind = 'status'
|
||||
THEN json_extract(e.payload_json, '$.status') END AS event_status,
|
||||
CASE WHEN e.kind = 'status' THEN
|
||||
json_extract(e.payload_json, '$.requestId') = scoped.id
|
||||
AND json_extract(e.payload_json, '$.type') = 'status'
|
||||
AND json_type(e.payload_json, '$.message') = 'text'
|
||||
END AS runtime_status,
|
||||
CASE WHEN e.kind = 'status' THEN (
|
||||
SELECT previous.created_at FROM task_events previous
|
||||
WHERE previous.task_id = scoped.id AND previous.id < e.id
|
||||
ORDER BY previous.id DESC LIMIT 1
|
||||
) END AS previous_at
|
||||
FROM scoped LEFT JOIN task_events e ON e.task_id = scoped.id
|
||||
AND e.kind IN ('status', 'done', 'error')
|
||||
ORDER BY scoped.id, e.id
|
||||
`).iterate(scopeId)
|
||||
let task: {
|
||||
id: string; proven: boolean; remote: boolean; incomplete: boolean
|
||||
start: number | undefined; last: number | undefined; duration: number
|
||||
conversationId: string | null; owner: string | null
|
||||
lastEvidence: number
|
||||
} | undefined
|
||||
const finish = (): void => {
|
||||
if (!task?.proven) return
|
||||
result.requestCount++
|
||||
observed.add(task.id)
|
||||
const key = bucket(task.conversationId, task.owner)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
if (activeRequestIds.has(task.id)) result.activeRequestCount++
|
||||
if (task.remote) {
|
||||
task.duration = 0
|
||||
task.incomplete = true
|
||||
} else if (task.start !== undefined) {
|
||||
if (Number.isFinite(task.lastEvidence) && task.lastEvidence >= task.start && task.lastEvidence <= result.asOf) {
|
||||
task.last = Math.max(task.last ?? task.start, task.lastEvidence)
|
||||
}
|
||||
const live = activeRequestIds.has(task.id) && task.start <= result.asOf
|
||||
task.duration += Math.max(0, (live ? result.asOf : task.last ?? task.start) - task.start)
|
||||
task.incomplete ||= !live
|
||||
}
|
||||
result.durationMs += task.duration
|
||||
if (task.incomplete) result.incompleteRequestCount++
|
||||
add(task.owner ?? task.id, task.duration, Number(task.incomplete))
|
||||
}
|
||||
for (const raw of rows) {
|
||||
const row = raw as unknown as {
|
||||
id: string; started_at: string | null; remote_recoverable: number
|
||||
linked: number; kind: string | null; created_at: string | null
|
||||
reply_event: number; remote_event: number; last_at: string | null; previous_at: string | null
|
||||
event_status: string | null; runtime_status: number | null; ssh_project: number | null
|
||||
conversation_id: string | null; visible: number; schedule_task_id: string | null
|
||||
}
|
||||
if (task?.id !== row.id) {
|
||||
finish()
|
||||
const start = Date.parse(row.started_at ?? '')
|
||||
task = {
|
||||
id: row.id, proven: row.linked === 1 || row.reply_event === 1 || row.schedule_task_id !== null || activeRequestIds.has(row.id),
|
||||
remote: row.remote_recoverable === 1 || row.remote_event === 1 || row.ssh_project === 1,
|
||||
lastEvidence: Date.parse(row.last_at ?? ''),
|
||||
conversationId: row.conversation_id,
|
||||
owner: row.schedule_task_id ?? (row.visible === 1 ? row.id : null),
|
||||
incomplete: !Number.isFinite(start),
|
||||
start: Number.isFinite(start) ? start : undefined,
|
||||
last: Number.isFinite(start) ? start : undefined, duration: 0
|
||||
}
|
||||
}
|
||||
const current = task!
|
||||
current.proven ||= row.runtime_status === 1
|
||||
if (row.kind === null) continue
|
||||
const status = row.event_status
|
||||
// Recovery writes interrupted at restart, not when execution actually stopped.
|
||||
if (status === 'interrupted') {
|
||||
if (current.start !== undefined) {
|
||||
const previous = Date.parse(row.previous_at ?? '')
|
||||
if (Number.isFinite(previous) && previous >= current.start && previous <= result.asOf) {
|
||||
current.last = Math.max(current.last ?? current.start, previous)
|
||||
}
|
||||
current.duration += Math.max(0, (current.last ?? current.start) - current.start)
|
||||
}
|
||||
current.start = undefined
|
||||
current.last = undefined
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
const time = Date.parse(row.created_at ?? '')
|
||||
if (!Number.isFinite(time) || time > result.asOf || (current.last !== undefined && time < current.last)) {
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
if (status === 'running' && current.start === undefined) current.start = time
|
||||
const closes = row.kind === 'done' || row.kind === 'error' ||
|
||||
['completed', 'failed', 'cancelled', 'paused', 'queued', 'idle'].includes(status ?? '')
|
||||
if (closes && current.start !== undefined) {
|
||||
current.duration += time - current.start
|
||||
current.start = undefined
|
||||
}
|
||||
current.last = time
|
||||
}
|
||||
finish()
|
||||
|
||||
// A schedule run has an independent request ID. Its stable task is never a clock.
|
||||
for (const row of database.prepare(`
|
||||
SELECT t.id, t.conversation_id, t.origin, t.schedule_id,
|
||||
sr.id AS run_id, sr.status AS run_status
|
||||
FROM tasks t LEFT JOIN schedule_runs sr ON sr.schedule_id = t.schedule_id
|
||||
WHERE t.${scopeColumn} = ? AND t.visible = 1 AND t.origin != 'subagent'
|
||||
ORDER BY t.id, sr.id
|
||||
`).iterate(scopeId)) {
|
||||
const id = row.id as string
|
||||
add(id, 0, 0)
|
||||
const key = bucket(row.conversation_id as string | null, id)
|
||||
// A removed plan without retained execution links cannot establish a zero.
|
||||
if (row.origin === 'schedule' && row.schedule_id === null && !coverage.has(key)) {
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, 1)
|
||||
}
|
||||
if (row.run_id && ['completed', 'failed', 'running'].includes(row.run_status as string) && !observed.has(row.run_id as string)) {
|
||||
observed.add(row.run_id as string)
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
// Local snapshots lack request IDs. Compare top-level assistant rows per
|
||||
// conversation/task, never nested subagent blocks or child-task messages.
|
||||
const messages = database.prepare(`
|
||||
SELECT m.conversation_id, m.request_id,
|
||||
COALESCE(parent.id, retained.id, json_extract(m.metadata_json, '$.task.id'),
|
||||
CASE WHEN mt.visible = 1 THEN mt.id END) AS owner,
|
||||
COUNT(*) AS count
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
||||
LEFT JOIN tasks mt ON mt.id = m.request_id
|
||||
LEFT JOIN schedule_runs sr ON sr.id = m.request_id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = mt.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
WHERE ${'conversationId' in scope ? 'm.conversation_id' : 'c.project_id'} = ?
|
||||
AND m.role = 'assistant'
|
||||
AND NOT (c.channel IS NULL AND m.sequence = 0 AND m.request_id IS NULL
|
||||
AND json_extract(m.metadata_json, '$.task.id') IS NULL
|
||||
AND m.content IN (?, ?))
|
||||
AND COALESCE(mt.origin, '') != 'subagent'
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks child WHERE child.origin = 'subagent'
|
||||
AND child.id = json_extract(m.metadata_json, '$.task.id'))
|
||||
GROUP BY m.conversation_id, owner, m.request_id
|
||||
ORDER BY m.request_id IS NULL
|
||||
`).all(
|
||||
scopeId,
|
||||
// Persisted greetings have no marker. Match only shipped default copy,
|
||||
// not arbitrary assistant-only/imported history or localized prefixes.
|
||||
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
|
||||
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.'
|
||||
)
|
||||
for (const row of messages) {
|
||||
const owner = row.owner as string | null
|
||||
const key = bucket(row.conversation_id as string, owner)
|
||||
const available = coverage.get(key) ?? 0
|
||||
const linked = row.request_id as string | null
|
||||
const count = linked ? 1 : row.count as number
|
||||
const covered = linked ? Number(observed.has(linked)) : Math.min(count, available)
|
||||
coverage.set(key, Math.max(0, available - covered))
|
||||
const missing = count - covered
|
||||
result.requestCount += missing
|
||||
result.incompleteRequestCount += missing
|
||||
add(owner, 0, missing)
|
||||
}
|
||||
result.taskDurations = [...durations.values()]
|
||||
return result
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
@@ -4424,13 +4651,18 @@ export class AssistantDatabase {
|
||||
work_mode, progress, created_at, started_at, visible,
|
||||
remote_recoverable, current_user_message_id,
|
||||
current_assistant_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
|
||||
VALUES (?, ?, ?, COALESCE((
|
||||
SELECT parent.id FROM schedule_runs sr
|
||||
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
WHERE sr.id = ?
|
||||
), ?), ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
|
||||
?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
taskId,
|
||||
input.projectId ?? null,
|
||||
input.conversationId ?? null,
|
||||
taskId,
|
||||
input.parentTaskId ?? null,
|
||||
input.expertId ?? null,
|
||||
input.routingMode ?? null,
|
||||
@@ -6486,7 +6718,7 @@ export class AssistantDatabase {
|
||||
(id, project_id, task_template_json, timezone, recurrence_json,
|
||||
next_run_at, missed_run_policy, enabled, last_run_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)`
|
||||
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
scheduleId,
|
||||
@@ -6497,7 +6729,11 @@ export class AssistantDatabase {
|
||||
workMode: parsed.workMode
|
||||
}),
|
||||
JSON.stringify({ type: parsed.recurrence }),
|
||||
parsed.nextRunAt,
|
||||
parsed.runImmediately ? now : parsed.nextRunAt,
|
||||
parsed.runImmediately ? 0 : 1,
|
||||
// Immediate creation consumes the once trigger even if its queue item
|
||||
// is cancelled. Further execution must use an explicit manual run.
|
||||
parsed.runImmediately ? now : null,
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -6521,6 +6757,22 @@ export class AssistantDatabase {
|
||||
parsed.workMode,
|
||||
now
|
||||
)
|
||||
if (parsed.runImmediately) {
|
||||
// Commit the task and its only automatic occurrence together. The timer
|
||||
// must never see an enabled immediate schedule between create and queue.
|
||||
const runId = randomUUID()
|
||||
database.prepare(
|
||||
`INSERT INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'pending')`
|
||||
).run(runId, scheduleId, now, taskId)
|
||||
database.prepare(
|
||||
`INSERT INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
schedule_run_id, schedule_id, task_id, status, created_at)
|
||||
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
|
||||
).run(runId, conversationId, parsed.title, runId, scheduleId, taskId, now)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -6605,6 +6857,16 @@ export class AssistantDatabase {
|
||||
if (activeRun) {
|
||||
throw new Error('定时任务正在运行,完成后才能删除计划')
|
||||
}
|
||||
// Preserve older requests' proven run ownership before the cascade removes it.
|
||||
database.prepare(`
|
||||
UPDATE tasks AS request SET parent_task_id = (
|
||||
SELECT parent.id FROM schedule_runs sr
|
||||
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
WHERE sr.id = request.id AND sr.schedule_id = ?
|
||||
)
|
||||
WHERE request.parent_task_id IS NULL
|
||||
AND request.id IN (SELECT id FROM schedule_runs WHERE schedule_id = ?)
|
||||
`).run(scheduleId, scheduleId)
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
describe('execution duration evidence', () => {
|
||||
let directory: string
|
||||
let database: AssistantDatabase
|
||||
let projectId: string
|
||||
let conversationId: string
|
||||
const epoch = Date.parse('2026-09-17T00:00:00Z')
|
||||
const at = (seconds: number): void => { vi.setSystemTime(epoch + seconds * 1000) }
|
||||
const request = (overrides: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
|
||||
const id = randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply', workMode: 'ask', visible: false, ...overrides })
|
||||
return id
|
||||
}
|
||||
const text = (id: string): void => database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-execution-stats-'))
|
||||
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
projectId = database.listProjects()[0]!.id
|
||||
conversationId = randomUUID()
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
at(0)
|
||||
})
|
||||
afterEach(async () => {
|
||||
database.close()
|
||||
vi.useRealTimers()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('sums hidden replies without round idle, counts approval/tool time, and scopes projects', () => {
|
||||
const first = request()
|
||||
text(first)
|
||||
at(2)
|
||||
database.updateTaskStatus(first, 'waiting_approval')
|
||||
at(8)
|
||||
database.updateTaskStatus(first, 'running')
|
||||
at(10)
|
||||
database.updateTaskStatus(first, 'completed')
|
||||
at(100)
|
||||
const second = request()
|
||||
text(second)
|
||||
at(105)
|
||||
database.updateTaskStatus(second, 'failed')
|
||||
const anotherConversation = request({ conversationId: randomUUID() })
|
||||
text(anotherConversation)
|
||||
at(108)
|
||||
database.updateTaskStatus(anotherConversation, 'cancelled')
|
||||
const unrelated = request({ projectId: undefined, conversationId: randomUUID() })
|
||||
text(unrelated)
|
||||
at(120)
|
||||
database.updateTaskStatus(unrelated, 'completed')
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.getExecutionStats({ conversationId })).toEqual({
|
||||
durationMs: 15000, requestCount: 2, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: epoch + 120000, taskDurations: []
|
||||
})
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 18000, requestCount: 3 })
|
||||
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
|
||||
})
|
||||
|
||||
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(schedule.taskId)
|
||||
const child = request({ origin: 'subagent', parentTaskId: schedule.taskId })
|
||||
text(child)
|
||||
const compact = request({ title: 'Compressed context' })
|
||||
const actual = request({ conversationId: schedule.conversationId, parentTaskId: schedule.taskId })
|
||||
text(actual)
|
||||
at(10)
|
||||
for (const id of [schedule.taskId, child, compact, actual]) database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('uses message request identity when a reply has no runtime events and does not use message timestamps', () => {
|
||||
const id = request()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Chat', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: '', state: 'error', createdAt: epoch - 86400000 }]
|
||||
}])
|
||||
// Only persisted request links are evidence; local snapshot saves do not supply them.
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE conversation_id = ?').run(id, conversationId)
|
||||
raw.close()
|
||||
at(3)
|
||||
database.updateTaskStatus(id, 'failed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('keeps only evidenced time for open and restart-interrupted replies', () => {
|
||||
const id = request()
|
||||
at(5)
|
||||
text(id)
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 100000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
database.close()
|
||||
at(86400)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
// A later resumed segment cannot include the shutdown/restart gap.
|
||||
at(86500)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(86510)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('segments repeated runs and pauses without using the final completed_at span', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(5)
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
at(100)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(107)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
at(200)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(203)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('does not substitute remote replay receipt time or unknown starts for actual execution time', () => {
|
||||
const remote = request()
|
||||
at(86400)
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: remote, type: 'done' } })
|
||||
database.updateTaskStatus(remote, 'completed')
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
at(86410)
|
||||
database.updateTaskStatus(unknown, 'completed')
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 2, incompleteRequestCount: 2 })
|
||||
})
|
||||
|
||||
it('includes all retained requests beyond list pagination', () => {
|
||||
for (let index = 0; index < 105; index++) {
|
||||
at(index * 10)
|
||||
const id = request()
|
||||
text(id)
|
||||
at(index * 10 + 1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
}
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 105000, requestCount: 105, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('groups real schedule request IDs into stable cards across runs without parent lifecycle time', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const idle = database.createSchedule({ projectId, conversationId: schedule.conversationId, title: 'Idle', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch + 86400000).toISOString() })
|
||||
for (const start of [100, 1000]) {
|
||||
at(start)
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeConversationUserQueueItem(queued.id)
|
||||
// Production agentRun uses scheduleRunId as requestId, without parentTaskId.
|
||||
const id = queued.scheduleRunId!
|
||||
request({ id, conversationId: schedule.conversationId })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(id)
|
||||
at(start + 10)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.completeTaskScheduleRun(id)
|
||||
}
|
||||
const live = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
request({ id: live.scheduleRunId!, conversationId: schedule.conversationId })
|
||||
text(live.scheduleRunId!)
|
||||
at(1025)
|
||||
const stats = database.getExecutionStats({ projectId }, new Set([live.scheduleRunId!]))
|
||||
expect(stats).toMatchObject({ durationMs: 35000, requestCount: 3, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(stats.taskDurations).toEqual(expect.arrayContaining([
|
||||
{ id: schedule.taskId, durationMs: 35000, incompleteRequestCount: 0 },
|
||||
{ id: idle.taskId, durationMs: 0, incompleteRequestCount: 0 }
|
||||
]))
|
||||
expect(stats.taskDurations.some(item => item.id === live.scheduleRunId)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('retains schedule execution totals after plan deletion (repair old link: %s)', (oldLink) => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Removable plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
at(100)
|
||||
const run = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
const task = database.createTask({ id: run.scheduleRunId!, projectId, conversationId: schedule.conversationId, title: 'Run', instructions: 'Reply', workMode: 'ask', visible: false })
|
||||
expect(task.parentTaskId).toBe(schedule.taskId)
|
||||
text(task.id)
|
||||
at(110)
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
database.completeTaskScheduleRun(task.id)
|
||||
if (oldLink) {
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET parent_task_id = NULL WHERE id = ?').run(task.id)
|
||||
raw.close()
|
||||
}
|
||||
const before = database.getExecutionStats({ projectId })
|
||||
at(10000)
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId })).toEqual({ ...before, asOf: epoch + 10000000 })
|
||||
expect(before.taskDurations).toEqual([{ id: schedule.taskId, durationMs: 10000, incompleteRequestCount: 0 }])
|
||||
database.close()
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual(before.taskDurations)
|
||||
})
|
||||
|
||||
it('does not claim a complete zero for an already removed plan with no retained execution relation', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old removed plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('recognizes runtime status-only replies but not maintenance statuses at restart', () => {
|
||||
const id = request()
|
||||
const maintenance = request({ title: 'Maintenance' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Processing request' })
|
||||
database.updateTaskStatus(maintenance, 'running')
|
||||
database.close()
|
||||
at(10000)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('marks SSH channel timing unknown before the first remote provenance and keeps that result on replay', () => {
|
||||
const project = database.createSshProject({
|
||||
project: { name: 'SSH project', description: '', rootPath: '/srv/workspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
|
||||
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/workspace' },
|
||||
assertCurrent: () => {}
|
||||
})
|
||||
const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'wecom', accountId: 'account', externalConversationId: 'external', conversationType: 'direct', title: 'SSH channel', accountDisplay: 'Account' })
|
||||
const id = request({ projectId: project.id, conversationId: conversation.id, origin: 'delegation' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Remote Agent is processing' })
|
||||
const expected = { durationMs: 0, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 1 }
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
at(100)
|
||||
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: id, type: 'text', delta: 'Reply' } })
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId: conversation.id })).toMatchObject({ ...expected, activeRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('marks retained assistant replies without request history incomplete and ignores nested subagent content', () => {
|
||||
const known = request()
|
||||
text(known)
|
||||
at(2)
|
||||
database.updateTaskStatus(known, 'completed')
|
||||
const child = request({ origin: 'subagent', parentTaskId: known })
|
||||
text(child)
|
||||
database.updateTaskStatus(child, 'completed')
|
||||
const childMessage = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Retained', updatedAt: epoch },
|
||||
messages: [
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Known reply', state: 'complete', createdAt: epoch, subagents: [] },
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Older reply without a task', state: 'complete', createdAt: epoch },
|
||||
{ id: childMessage, role: 'assistant', content: 'Child reply', state: 'complete', createdAt: epoch }
|
||||
]
|
||||
}])
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE id = ?').run(child, childMessage)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('flags missing schedule execution once even when its assistant message is retained', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Lost run', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeScheduleRun(queued.scheduleRunId!, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Lost run', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained result', state: 'complete', createdAt: epoch, task: { id: schedule.taskId, title: 'Lost run' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({
|
||||
durationMs: 0, requestCount: 1, incompleteRequestCount: 1,
|
||||
taskDurations: [{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot make a remote or missing-start request complete just because it has a live lease', () => {
|
||||
const remote = request()
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: remote, type: 'text', delta: 'Remote' } })
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([remote, unknown]))).toMatchObject({ durationMs: 0, incompleteRequestCount: 2, activeRequestCount: 2 })
|
||||
})
|
||||
|
||||
it('counts confirmed live replies before their first event without counting maintenance leases', () => {
|
||||
const id = request()
|
||||
request({ title: 'Context compression' })
|
||||
at(10)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('reports wholly missing reply history and task attribution without using message timestamps', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old task', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Old conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Old reply', state: 'complete', createdAt: epoch - 100000, task: { id: schedule.taskId, title: 'Old task' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ conversationId: schedule.conversationId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('does not let request coverage in another conversation conceal missing replies', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: randomUUID(), projectId, title: 'Other conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Missing history', state: 'complete', createdAt: epoch }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 1000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。'],
|
||||
['en-US', 'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.']
|
||||
])('excludes the persisted default greeting but retains unknown reply history (%s)', async (locale, greeting) => {
|
||||
expect(await readFile(join(process.cwd(), 'src/renderer/src/i18n/locales', locale, 'app.ts'), 'utf8')).toContain(greeting)
|
||||
const header = { id: conversationId, projectId, title: 'New conversation', updatedAt: epoch }
|
||||
const welcome = { id: randomUUID(), role: 'assistant' as const, content: greeting, state: 'complete' as const, createdAt: epoch }
|
||||
database.saveLocalConversations([{ header, messages: [welcome] }])
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 0, incompleteRequestCount: 0 })
|
||||
database.saveLocalConversations([{ header, messages: [welcome,
|
||||
{ id: randomUUID(), role: 'user', content: 'Question', state: 'complete', createdAt: epoch + 1 },
|
||||
{ id: randomUUID(), role: 'assistant', content: greeting, state: 'complete', createdAt: epoch + 2 }
|
||||
] }])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('seeks interruption boundaries without parsing large tool/delta payloads on repeated scope queries', () => {
|
||||
const id = request()
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
|
||||
try {
|
||||
const insert = raw.prepare('INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)')
|
||||
const payload = JSON.stringify({ requestId: id, output: 'x'.repeat(32768) })
|
||||
raw.exec('BEGIN')
|
||||
for (let index = 0; index < 3000; index++) {
|
||||
insert.run(id, index % 2 ? 'text' : 'tool', payload, new Date(epoch + 5000).toISOString())
|
||||
}
|
||||
raw.exec('COMMIT')
|
||||
at(100)
|
||||
database.updateTaskStatus(id, 'interrupted')
|
||||
const baseline = raw.prepare("SELECT SUM(length(json_extract(payload_json, '$.requestId'))) FROM task_events WHERE task_id = ?")
|
||||
const baselineStart = performance.now()
|
||||
baseline.get(id)
|
||||
baseline.get(id)
|
||||
const baselineMs = performance.now() - baselineStart
|
||||
// One visible-panel tick reads both scopes; repeat to exercise warm polling.
|
||||
const start = performance.now()
|
||||
for (let iteration = 0; iteration < 5; iteration++) {
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
}
|
||||
const pairMs = (performance.now() - start) / 5
|
||||
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
|
||||
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
|
||||
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
|
||||
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
|
||||
}
|
||||
// Malformed bodies make accidental JSON extraction fail deterministically.
|
||||
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
console.info(`execution stats: 3000 events, ${Math.round(payload.length * 3000 / 1024 / 1024)} MiB payload; old JSON extraction pair ${baselineMs.toFixed(1)} ms; scalar scope pair ${pairMs.toFixed(1)} ms`)
|
||||
} finally {
|
||||
prepare.mockRestore()
|
||||
raw.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
database.close()
|
||||
|
||||
const raw = new DatabaseSync(path)
|
||||
raw.exec('PRAGMA user_version = 2')
|
||||
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 2')
|
||||
raw.close()
|
||||
|
||||
const migrated = new AssistantDatabase(path)
|
||||
@@ -128,7 +128,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
'DELETE FROM heartbeat_config_projects WHERE config_id = ?'
|
||||
)
|
||||
.run(config.id)
|
||||
raw.exec('PRAGMA user_version = 20')
|
||||
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 20')
|
||||
raw.close()
|
||||
|
||||
const migrated = new AssistantDatabase(path)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const input = {
|
||||
title: 'Immediate task',
|
||||
prompt: 'Run exactly once',
|
||||
recurrence: 'once' as const,
|
||||
nextRunAt: '2020-01-01T00:00:00.000Z',
|
||||
runImmediately: true
|
||||
}
|
||||
|
||||
describe('immediate schedule creation', () => {
|
||||
it('commits one real queue occurrence and never rematerializes it on scheduler ticks', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const schedule = database.createSchedule(input)
|
||||
expect(schedule.enabled).toBe(false)
|
||||
expect(schedule).not.toHaveProperty('runImmediately')
|
||||
const [item] = database.listConversationQueueItems(schedule.conversationId)
|
||||
expect(item).toMatchObject({ scheduleId: schedule.id, taskId: schedule.taskId })
|
||||
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('queued')
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
const claimed = database.claimConversationQueueItem(schedule.conversationId)
|
||||
expect(claimed).toMatchObject({ source: 'schedule', runId: item!.id })
|
||||
expect(database.claimConversationQueueItem(schedule.conversationId)).toBeUndefined()
|
||||
database.completeScheduleRun(item!.id, 'completed')
|
||||
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('completed')
|
||||
expect(database.listSchedules()[0]).toMatchObject({ enabled: false, lastRunAt: expect.any(String) })
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back conversation, task and schedule when queue insertion fails, so retry creates only one', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-schedule-create-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
sql.exec(`CREATE TRIGGER reject_queue BEFORE INSERT ON conversation_queue_items
|
||||
BEGIN SELECT RAISE(ABORT, 'queue unavailable'); END;`)
|
||||
expect(() => database.createSchedule(input)).toThrow('queue unavailable')
|
||||
expect(database.listSchedules()).toEqual([])
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.listConversations()).toEqual([])
|
||||
expect(sql.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()?.count).toBe(0)
|
||||
sql.exec('DROP TRIGGER reject_queue')
|
||||
database.createSchedule(input)
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
expect(database.listTasks()).toHaveLength(1)
|
||||
expect(database.listConversations()).toHaveLength(1)
|
||||
expect(database.listConversationQueueItems()).toHaveLength(1)
|
||||
} finally {
|
||||
sql.close()
|
||||
database.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes the immediate trigger even when cancelled and requires an explicit new run', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const schedule = database.createSchedule(input)
|
||||
expect(schedule.lastRunAt).toBe(schedule.nextRunAt)
|
||||
const [item] = database.listConversationQueueItems(schedule.conversationId)
|
||||
database.cancelConversationQueueItem(item!.id)
|
||||
expect(database.listSchedules()[0]).toMatchObject({
|
||||
enabled: false, lastRunAt: schedule.lastRunAt
|
||||
})
|
||||
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
|
||||
expect(() => database.setScheduleEnabled(schedule.id, true)).toThrow(
|
||||
'已执行的一次性计划不能恢复自动运行'
|
||||
)
|
||||
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
|
||||
const tomorrow = new Date(Date.parse(schedule.nextRunAt) + 86_400_000)
|
||||
expect(database.queueDueSchedules(tomorrow)).toEqual([])
|
||||
|
||||
const retry = database.queueScheduleNow(schedule.id)
|
||||
expect(retry.id).not.toBe(item!.id)
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
expect(database.listConversationQueueItems()).toHaveLength(1)
|
||||
expect(database.getTask(schedule.taskId).status).toBe('queued')
|
||||
expect(database.claimConversationQueueItem(schedule.conversationId)).toMatchObject({
|
||||
runId: retry.id
|
||||
})
|
||||
database.completeScheduleRun(retry.id, 'completed')
|
||||
expect(database.getTask(schedule.taskId).status).toBe('completed')
|
||||
expect(database.queueDueSchedules(tomorrow)).toEqual([])
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['once', 'daily', 'weekly'] as const)('preserves pause and manual-run cancellation for a future %s schedule', (recurrence) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const nextRunAt = new Date(Date.now() + 86_400_000).toISOString()
|
||||
const schedule = database.createSchedule({
|
||||
...input, runImmediately: false, recurrence, nextRunAt
|
||||
})
|
||||
database.setScheduleEnabled(schedule.id, false)
|
||||
const manual = database.queueScheduleNow(schedule.id)
|
||||
database.cancelConversationQueueItem(manual.id)
|
||||
expect(database.listSchedules()[0]).toMatchObject({
|
||||
enabled: false, lastRunAt: undefined, nextRunAt
|
||||
})
|
||||
expect(database.queueDueSchedules(new Date(nextRunAt))).toEqual([])
|
||||
database.setScheduleEnabled(schedule.id, true)
|
||||
expect(database.queueDueSchedules(new Date(nextRunAt))).toHaveLength(1)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps scheduled creation enabled and rejects recurring immediate requests', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
expect(() => database.createSchedule({ ...input, recurrence: 'daily' })).toThrow('立即执行只支持单次任务')
|
||||
const schedule = database.createSchedule({ ...input, runImmediately: false })
|
||||
expect(schedule.enabled).toBe(true)
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
expect(database.queueDueSchedules()).toHaveLength(1)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import {
|
||||
documentOcrFailureSchema,
|
||||
@@ -59,7 +60,7 @@ export class DocumentOcrBroker {
|
||||
1_000
|
||||
)
|
||||
)
|
||||
return new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
return localInferenceService.run('ocr', '文档解析', () => new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
const onAbort = (): void =>
|
||||
this.cancelRequest(request.requestId, 'OCR 解析已取消')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
@@ -78,7 +79,7 @@ export class DocumentOcrBroker {
|
||||
return
|
||||
}
|
||||
this.dispatchNext()
|
||||
})
|
||||
}), { model: request.modelId, cancelUnavailableReason: '现有 OCR 取消会终止共享 Worker;管理页不将服务释放用作单任务取消。' })
|
||||
}
|
||||
|
||||
respond(input: unknown): void {
|
||||
|
||||
@@ -96,6 +96,23 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('GoodBuddyConfigService', () => {
|
||||
it('publishes persisted application changes from config apply through the settings event', async () => {
|
||||
const { application, service, workspace } = await createHarness()
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = application.onChanged(changed)
|
||||
const current = await application.get()
|
||||
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
|
||||
const plan = await service.plan('settings-sync', workspace, {
|
||||
operations: [{ operation: 'application.update', updates: { applicationNavigation } }],
|
||||
})
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal, async () => true)
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
|
||||
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
|
||||
unsubscribe()
|
||||
await application.update({ localInferenceEnabled: false })
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('returns only sanitized configuration and publishes common examples', async () => {
|
||||
const { service, capabilities } = await createHarness()
|
||||
await capabilities.saveMcpServer(undefined, {
|
||||
|
||||
+67
-53
@@ -1,3 +1,5 @@
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -45,10 +47,7 @@ import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
||||
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
|
||||
import type { EmbeddingProvider } from './knowledge/types'
|
||||
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
|
||||
import {
|
||||
EmbeddingInferenceBroker,
|
||||
type EmbeddingInferenceTransport
|
||||
} from './knowledge/embedding-inference-broker'
|
||||
import { EmbeddingInferenceBroker } from './knowledge/embedding-inference-broker'
|
||||
import { CohereRerankClient } from './knowledge/cohere-rerank-client'
|
||||
import { RuntimeSettingsStore } from './runtime-settings-store'
|
||||
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
|
||||
@@ -220,6 +219,7 @@ let documentOcrBroker: DocumentOcrBroker | undefined
|
||||
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
||||
let embeddingModelManager: EmbeddingModelManager | undefined
|
||||
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
|
||||
const sharedEmbeddingBrokers = new Map<string, { broker: EmbeddingInferenceBroker; users: number; unregister: () => void }>()
|
||||
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
|
||||
let remoteAgentServices: RemoteAgentServices | undefined
|
||||
@@ -234,47 +234,6 @@ type ManagedEmbeddingProvider = EmbeddingProvider & {
|
||||
dispose?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function createEmbeddingUtilityTransport(
|
||||
modulePath: string,
|
||||
modelDirectory: string
|
||||
): EmbeddingInferenceTransport {
|
||||
const child = utilityProcess.fork(
|
||||
modulePath,
|
||||
[modelDirectory],
|
||||
{
|
||||
serviceName: 'GoodBuddy Embedding Inference',
|
||||
stdio: 'pipe',
|
||||
allowLoadingUnsignedLibraries: false
|
||||
}
|
||||
)
|
||||
return {
|
||||
postMessage: (message) => child.postMessage(message),
|
||||
onMessage: (listener) => {
|
||||
child.on('message', listener)
|
||||
return () => child.removeListener('message', listener)
|
||||
},
|
||||
onClose: (listener) => {
|
||||
let closed = false
|
||||
const notify = (): void => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
listener()
|
||||
}
|
||||
child.on('exit', notify)
|
||||
child.on('error', notify)
|
||||
return () => {
|
||||
child.removeListener('exit', notify)
|
||||
child.removeListener('error', notify)
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmbeddingProvider(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
manager: EmbeddingModelManager,
|
||||
@@ -320,14 +279,38 @@ async function createEmbeddingProvider(
|
||||
if (!tokenizerDigest || !modelDigest) {
|
||||
throw new Error('内置向量模型安装清单不完整')
|
||||
}
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
createTransport: () =>
|
||||
createEmbeddingUtilityTransport(
|
||||
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
|
||||
modelDirectory
|
||||
)
|
||||
})
|
||||
embeddingBrokers.add(broker)
|
||||
let shared = sharedEmbeddingBrokers.get(modelDirectory)
|
||||
if (!shared) {
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
createTransport: () =>
|
||||
createEmbeddingUtilityTransport(
|
||||
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
|
||||
modelDirectory
|
||||
)
|
||||
})
|
||||
embeddingBrokers.add(broker)
|
||||
const unregister = localInferenceService.register('embedding', {
|
||||
snapshot: () => ({
|
||||
id: 'embedding', name: '向量生成', engine: 'Granite / ONNX',
|
||||
ownership: 'managed-process', state: broker.getManagementState(), model: model.id,
|
||||
error: broker.getManagementError(),
|
||||
resources: broker.getResources(),
|
||||
detail: 'GoodBuddy 托管进程,由知识检索与索引共享。进程启动后模型在首个请求时加载;资源指标为整个向量服务进程占用。停止会中断使用方任务,启动不重放任务。',
|
||||
actions: broker.getManagementState() === 'stopped' || broker.getManagementState() === 'idle' || broker.getManagementState() === 'error'
|
||||
? ['start'] : broker.getManagementState() === 'running' ? ['stop', 'restart'] : []
|
||||
}),
|
||||
act: async (action) => {
|
||||
if (action !== 'start') await broker.stop()
|
||||
if (action !== 'stop') await broker.start()
|
||||
}
|
||||
})
|
||||
shared = { broker, users: 0, unregister }
|
||||
sharedEmbeddingBrokers.set(modelDirectory, shared)
|
||||
}
|
||||
shared.users += 1
|
||||
const sharedBroker = shared
|
||||
const broker = shared.broker
|
||||
let disposed = false
|
||||
return {
|
||||
provider: 'builtin',
|
||||
model: model.id,
|
||||
@@ -354,6 +337,12 @@ async function createEmbeddingProvider(
|
||||
embedDocuments: (texts, signal) =>
|
||||
broker.embed(texts, 'document', signal),
|
||||
dispose: async () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
sharedBroker.users -= 1
|
||||
if (sharedBroker.users > 0) return
|
||||
sharedEmbeddingBrokers.delete(modelDirectory)
|
||||
sharedBroker.unregister()
|
||||
try {
|
||||
await broker.shutdown()
|
||||
} finally {
|
||||
@@ -803,6 +792,19 @@ if (hasSingleInstanceLock) {
|
||||
documentOcrModelManager,
|
||||
documentOcrBroker
|
||||
)
|
||||
localInferenceService.register('ocr', {
|
||||
snapshot: async () => {
|
||||
const snapshot = await documentParsingService.snapshot()
|
||||
const model = snapshot.status.localOcr
|
||||
return {
|
||||
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
|
||||
resources: { scope: 'unavailable', reason: 'Web Worker 与界面共享渲染进程,无法独立统计 CPU / 内存' },
|
||||
state: model.available ? 'unknown' : 'unavailable', model: model.id,
|
||||
detail: `${model.detail}。模型由 Renderer Worker 持有,文档解析按需加载,空闲 60 秒自动释放;仅允许无活动任务时手动释放。`,
|
||||
actions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const versionChecker = new VersionChecker({
|
||||
fetch: globalThis.fetch,
|
||||
currentVersion: app.getVersion(),
|
||||
@@ -818,6 +820,18 @@ if (hasSingleInstanceLock) {
|
||||
const speechTranscriptionService = new SpeechTranscriptionService(
|
||||
speechModelManager
|
||||
)
|
||||
localInferenceService.register('asr', {
|
||||
snapshot: async () => {
|
||||
const model = await speechModelManager.getSelectedRuntimeModel()
|
||||
return {
|
||||
id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker',
|
||||
resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' },
|
||||
state: model ? 'unknown' : 'unavailable', model: model?.id,
|
||||
detail: model ? '每个识别请求独立加载模型,完成后释放;支持取消单次识别,没有常驻服务可启停。' : '请在设置中安装并选择本地语音模型。',
|
||||
actions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
browserService = new BrowserService({ parentWindow: mainWindow })
|
||||
const bundledRuntimePaths = resolveBundledRuntimePaths({
|
||||
appPath: app.getAppPath(),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProcessMetric } from 'electron'
|
||||
import { createInferenceProcessSampler } from './inference-process-resources'
|
||||
|
||||
describe('inference process resources', () => {
|
||||
it('measures only the utility PID, warms CPU and converts KiB to bytes', () => {
|
||||
let time = 0
|
||||
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { cumulativeCPUUsage: 2, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 2048, peakWorkingSetSize: 2048 } }
|
||||
const sample = createInferenceProcessSampler(() => 42, () => [{ ...metric, pid: 99 }, metric], () => time)
|
||||
expect(sample()).toMatchObject({ scope: 'service-process', pid: 42, workingSetBytes: 2097152, cpuUnavailableReason: '正在建立采样基线' })
|
||||
time = 5100
|
||||
metric.cpu.cumulativeCPUUsage = 9.65
|
||||
expect(sample()).toMatchObject({ cpuPercent: 150 })
|
||||
time = 21000
|
||||
expect(sample()).toMatchObject({ cpuUnavailableReason: '正在建立采样基线' })
|
||||
time = 26000
|
||||
metric.creationTime = 2
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
time = 31000
|
||||
metric.cpu.cumulativeCPUUsage = 0
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
})
|
||||
|
||||
it('clears baselines on exit, missing metrics and failure; preserves partial readings', () => {
|
||||
let pid: number | undefined = 42
|
||||
let time = 0
|
||||
let fail = false
|
||||
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }
|
||||
const sample = createInferenceProcessSampler(() => pid, () => { if (fail) throw new Error('sampling'); return [metric] }, () => time += 2000)
|
||||
expect(sample()).toMatchObject({ workingSetBytes: 1048576, cpuUnavailableReason: '系统未提供进程 CPU 时间' })
|
||||
metric.cpu.cumulativeCPUUsage = 1
|
||||
metric.memory.workingSetSize = NaN
|
||||
expect(sample()).toMatchObject({ memoryUnavailableReason: '系统未提供工作集内存' })
|
||||
fail = true
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '进程资源采样失败' })
|
||||
fail = false
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
pid = undefined
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '服务进程未运行' })
|
||||
pid = 43
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '等待进程资源数据' })
|
||||
metric.pid = 43
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
metric.type = 'Tab'
|
||||
expect(sample()).toMatchObject({ scope: 'unavailable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ProcessMetric } from 'electron'
|
||||
import type { InferenceResources } from '../shared/local-inference-contracts'
|
||||
|
||||
// One sampler per transport: a replacement process never inherits the old baseline.
|
||||
export function createInferenceProcessSampler(
|
||||
getPid: () => number | undefined,
|
||||
getMetrics: () => ProcessMetric[],
|
||||
now: () => number = () => performance.now()
|
||||
): () => InferenceResources {
|
||||
let previous: { pid: number; creationTime: number; time: number; cpu: number } | undefined
|
||||
return () => {
|
||||
const pid = getPid()
|
||||
let metric: ProcessMetric | undefined
|
||||
try {
|
||||
if (pid !== undefined) metric = getMetrics().find((entry) => entry.pid === pid && entry.type === 'Utility')
|
||||
} catch {
|
||||
previous = undefined
|
||||
return { scope: 'unavailable', reason: '进程资源采样失败' }
|
||||
}
|
||||
if (!metric) {
|
||||
previous = undefined
|
||||
return { scope: 'unavailable', reason: pid === undefined ? '服务进程未运行' : '等待进程资源数据' }
|
||||
}
|
||||
const time = now()
|
||||
const cpu = metric.cpu.cumulativeCPUUsage
|
||||
const resources: InferenceResources = {
|
||||
scope: 'service-process', pid: metric.pid, sampledAt: Date.now(),
|
||||
cpuUnavailableReason: '正在建立采样基线'
|
||||
}
|
||||
// Electron reports working set in KiB. Do not replace absent readings with zero.
|
||||
const memory = metric.memory?.workingSetSize
|
||||
if (Number.isFinite(memory) && memory >= 0) resources.workingSetBytes = memory * 1024
|
||||
else resources.memoryUnavailableReason = '系统未提供工作集内存'
|
||||
if (cpu !== undefined && Number.isFinite(cpu) && cpu >= 0) {
|
||||
if (previous && previous.pid === metric.pid && previous.creationTime === metric.creationTime &&
|
||||
time > previous.time && time - previous.time <= 15000 && cpu >= previous.cpu) {
|
||||
// Single-core capacity is 100%; multithreaded inference may exceed 100%.
|
||||
resources.cpuPercent = (cpu - previous.cpu) * 100_000 / (time - previous.time)
|
||||
resources.cpuUnavailableReason = undefined
|
||||
}
|
||||
previous = { pid: metric.pid, creationTime: metric.creationTime, time, cpu }
|
||||
} else {
|
||||
previous = undefined
|
||||
resources.cpuUnavailableReason = '系统未提供进程 CPU 时间'
|
||||
}
|
||||
return resources
|
||||
}
|
||||
}
|
||||
+158
-5
@@ -12,6 +12,7 @@ import { join } from 'node:path'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
ConversationSnapshot,
|
||||
ConversationQueueItem
|
||||
} from '../shared/assistant-contracts'
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
runtimeSettingsInputSchema,
|
||||
type AgentEvent,
|
||||
type BrowserLiveState,
|
||||
type BrowserTabId
|
||||
type BrowserTabId,
|
||||
type ConversationQueueDispatch
|
||||
} from '../shared/contracts'
|
||||
import type {
|
||||
SshHostAgentConnectionStatus
|
||||
@@ -1056,6 +1058,8 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
const versionChecker = {
|
||||
check: vi.fn(async () => result)
|
||||
}
|
||||
const unsubscribe = vi.fn()
|
||||
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
@@ -1076,10 +1080,14 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ get: getApplicationSettings } as never,
|
||||
{ get: getApplicationSettings, onChanged } as never,
|
||||
versionChecker as never
|
||||
)
|
||||
|
||||
const changedSettings = { updateSource: 'github' }
|
||||
onChanged.mock.calls[0]
|
||||
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
|
||||
).resolves.toEqual(result)
|
||||
@@ -1111,6 +1119,7 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
)
|
||||
|
||||
await dispose()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1177,6 +1186,7 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
snapshot: vi.fn(async () => ocrSnapshot)
|
||||
}
|
||||
const applicationSettingsStore = {
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
}))
|
||||
@@ -1781,7 +1791,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
undefined, // subagentService
|
||||
undefined, // channelSettingsStore
|
||||
{
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
|
||||
onChanged: vi.fn()
|
||||
} as never, // applicationSettingsStore
|
||||
undefined, // versionChecker
|
||||
undefined, // speechModelManager
|
||||
@@ -2212,6 +2223,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
const applicationSettingsStore = {
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
|
||||
}
|
||||
const sshHostService = {
|
||||
@@ -4235,6 +4247,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => {
|
||||
throw new Error('settings unavailable')
|
||||
})
|
||||
@@ -4323,6 +4336,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
||||
} as never
|
||||
)
|
||||
@@ -4363,6 +4377,42 @@ describe('registerIpcHandlers token usage', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates execution statistics scope and sender before reading evidence', async () => {
|
||||
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
|
||||
const assistantDatabase = {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getExecutionStats: vi.fn(() => summary)
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
||||
}
|
||||
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never, { capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
||||
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
|
||||
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
|
||||
)
|
||||
try {
|
||||
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const id = '00000000-0000-4000-8000-000000000301'
|
||||
for (const scope of [{ conversationId: id }, { projectId: id }]) {
|
||||
expect(handler(event, scope)).toBe(summary)
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
|
||||
}
|
||||
expect(() => handler(event, {})).toThrow()
|
||||
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
|
||||
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the database token summary to a trusted renderer', async () => {
|
||||
const summary = {
|
||||
totals: {
|
||||
@@ -5273,7 +5323,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
subagentService as never,
|
||||
undefined,
|
||||
{
|
||||
get: getApplicationSettings
|
||||
get: getApplicationSettings,
|
||||
onChanged: vi.fn()
|
||||
} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -5516,7 +5567,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
title: '每周汇总',
|
||||
prompt: '汇总本周进展',
|
||||
recurrence: 'weekly' as const,
|
||||
nextRunAt: '2026-08-21T09:00:00.000Z'
|
||||
nextRunAt: new Date(Date.now() + 60_000).toISOString()
|
||||
}
|
||||
|
||||
await electronMocks.handlers.get(
|
||||
@@ -5532,6 +5583,68 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const conversationId = crypto.randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
|
||||
messages: []
|
||||
}])
|
||||
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
||||
if (status === 'failed') {
|
||||
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
|
||||
return
|
||||
}
|
||||
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
|
||||
yield { type: 'done' as const, requestId: request.requestId }
|
||||
})
|
||||
const harness = createHarness(
|
||||
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
|
||||
undefined, 'always', undefined, false, undefined, undefined, undefined,
|
||||
false, undefined, undefined, undefined, undefined, database
|
||||
)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
try {
|
||||
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
||||
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
|
||||
const input = {
|
||||
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
|
||||
nextRunAt: '2020-01-01T00:00:00.000Z'
|
||||
}
|
||||
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
|
||||
expect(database.listSchedules()).toEqual([])
|
||||
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
|
||||
expect(schedule.enabled).toBe(false)
|
||||
const dispatches = () => harness.webContents.send.mock.calls.filter(
|
||||
([channel]) => channel === ipcChannels.conversationQueueDispatch
|
||||
)
|
||||
expect(dispatches()).toHaveLength(1)
|
||||
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
|
||||
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
await harness.handler!(event, {
|
||||
requestId: dispatch.item.id,
|
||||
conversationId,
|
||||
queueItemId: dispatch.item.id,
|
||||
prompt: input.prompt,
|
||||
workMode: 'ask',
|
||||
currentUserMessageId: crypto.randomUUID(),
|
||||
currentAssistantMessageId: crypto.randomUUID()
|
||||
})
|
||||
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
|
||||
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
expect(dispatches()).toHaveLength(1)
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
|
||||
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
|
||||
const taskId = '00000000-0000-4000-8000-000000000701'
|
||||
@@ -7814,6 +7927,46 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('retains user-enabled knowledge scopes when other applications are disabled', async () => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
let finish: (() => void) | undefined
|
||||
const held = new Promise<void>(resolve => { finish = resolve })
|
||||
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
|
||||
if (request.requestId.endsWith('21')) await held
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
})
|
||||
const retrieveMany = vi.fn(async () => [])
|
||||
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
|
||||
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, {
|
||||
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
|
||||
}, gateway)
|
||||
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
|
||||
try {
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(retrieveMany).toHaveBeenCalledOnce()
|
||||
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
||||
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(retrieveMany).toHaveBeenCalledTimes(2)
|
||||
expect(gateway.grant).toHaveBeenCalledTimes(2)
|
||||
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
|
||||
finish!()
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
|
||||
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
|
||||
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(retrieveMany).toHaveBeenCalledTimes(3)
|
||||
expect(request.knowledgeLibraryIds).toEqual([libraryId])
|
||||
} finally {
|
||||
finish!()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
|
||||
+62
-5
@@ -1,5 +1,7 @@
|
||||
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -200,6 +202,7 @@ import {
|
||||
} from '../shared/magic-notes-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
executionStatsInputSchema,
|
||||
activityHistorySnapshotSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotsSchema,
|
||||
@@ -1345,6 +1348,7 @@ export function registerIpcHandlers(
|
||||
): () => Promise<void> {
|
||||
type ActiveRequestLease = {
|
||||
controller: AbortController
|
||||
isReply: boolean
|
||||
conversationId: string
|
||||
detachOnApplicationExit: boolean
|
||||
teamMode?: boolean
|
||||
@@ -1375,10 +1379,12 @@ export function registerIpcHandlers(
|
||||
const leaseActiveRequest = (
|
||||
requestId: string,
|
||||
conversationId: string,
|
||||
controller: AbortController
|
||||
controller: AbortController,
|
||||
isReply = true
|
||||
): ActiveRequestLease => {
|
||||
const lease: ActiveRequestLease = {
|
||||
controller,
|
||||
isReply,
|
||||
conversationId,
|
||||
detachOnApplicationExit: false,
|
||||
release: (): void => {
|
||||
@@ -1580,6 +1586,11 @@ export function registerIpcHandlers(
|
||||
}
|
||||
window.on('maximize', notifyMaximizedChanged)
|
||||
window.on('unmaximize', notifyMaximizedChanged)
|
||||
const removeApplicationSettingsListener = applicationSettingsStore?.onChanged((settings) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.applicationSettingsChanged, settings)
|
||||
}
|
||||
})
|
||||
const removeBrowserStateListener = browserControl?.onState((state) => {
|
||||
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
|
||||
window.webContents.send(ipcChannels.browserState, state)
|
||||
@@ -5117,7 +5128,8 @@ export function registerIpcHandlers(
|
||||
const activeRequestLease = leaseActiveRequest(
|
||||
request.requestId,
|
||||
request.conversationId,
|
||||
controller
|
||||
controller,
|
||||
false
|
||||
)
|
||||
assistantDatabase.createTask({
|
||||
id: request.requestId,
|
||||
@@ -6480,6 +6492,29 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.localInferenceGet, async (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
const snapshot = await localInferenceService.snapshot()
|
||||
const settings = await settingsStore.getPublicSettings()
|
||||
return {
|
||||
...snapshot,
|
||||
externalConnections: settings.embeddingConnections?.filter((connection) => connection.kind !== 'builtin')
|
||||
.map((connection) => ({ id: connection.id, name: connection.name, model: connection.modelName })) ?? []
|
||||
}
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceOpenSettings, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
window.webContents.send(ipcChannels.settingsOpen)
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceAct, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return trackExecution(localInferenceService.act(inferenceActionSchema.parse(input)))
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceCancel, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
localInferenceService.cancel(inferenceCancelSchema.parse(input).taskId)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.speechModelsGet, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!speechModelManager) {
|
||||
@@ -7220,6 +7255,13 @@ export function registerIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.listTasks()
|
||||
})
|
||||
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getExecutionStats(
|
||||
executionStatsInputSchema.parse(input),
|
||||
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
|
||||
)
|
||||
})
|
||||
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = taskStatusRequestSchema.parse(input)
|
||||
@@ -7398,9 +7440,23 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.schedulesCreate, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.createSchedule(
|
||||
scheduleCreateSchema.parse(input)
|
||||
)
|
||||
const value = scheduleCreateSchema.parse(input)
|
||||
if (value.runImmediately) {
|
||||
if (executionPaused || shuttingDown) {
|
||||
throw new Error('本地数据维护期间暂不接受新任务')
|
||||
}
|
||||
} else if (new Date(value.nextRunAt).getTime() <= Date.now()) {
|
||||
throw new Error('首次运行时间必须晚于当前时间。')
|
||||
}
|
||||
const schedule = assistantDatabase.createSchedule(value)
|
||||
if (value.runImmediately) {
|
||||
publishConversationQueueChange(schedule.conversationId)
|
||||
if (!isConversationExecuting(schedule.conversationId)) {
|
||||
readyConversationQueues.add(schedule.conversationId)
|
||||
void pumpConversationQueue(schedule.conversationId)
|
||||
}
|
||||
}
|
||||
return schedule
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
@@ -8737,6 +8793,7 @@ export function registerIpcHandlers(
|
||||
|
||||
return async () => {
|
||||
shuttingDown = true
|
||||
removeApplicationSettingsListener?.()
|
||||
removeLocalToolEnvironmentProgressListener?.()
|
||||
await localToolEnvironmentService?.dispose()
|
||||
removeBrowserStateListener?.()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import { EmbeddingInferenceWorker } from './embedding-inference-worker'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
EMBEDDING_INFERENCE_PROTOCOL,
|
||||
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
|
||||
type EmbeddingInferenceRequest
|
||||
@@ -62,6 +65,255 @@ function embedRequest(transport: TestTransport): Extract<
|
||||
}
|
||||
|
||||
describe('EmbeddingInferenceBroker', () => {
|
||||
it('drops exited process resources and uses the replacement transport after restart', async () => {
|
||||
const first = new TestTransport()
|
||||
const second = new TestTransport()
|
||||
const resourceTransport = (transport: TestTransport, pid: number): EmbeddingInferenceTransport => Object.assign(transport, {
|
||||
getResources: () => ({ scope: 'service-process' as const, pid, sampledAt: 1, workingSetBytes: 1024 })
|
||||
})
|
||||
const factory = vi.fn().mockReturnValueOnce(resourceTransport(first, 42)).mockReturnValueOnce(resourceTransport(second, 43))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 42 })
|
||||
first.crash()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 43 })
|
||||
await broker.stop()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps source-cancelled computation in stop impact until the worker returns', async () => {
|
||||
const transport = new TestTransport()
|
||||
let finish!: (vectors: number[][]) => void
|
||||
let workerSignal!: AbortSignal
|
||||
const engine = {
|
||||
tokenizer: { tokenize: vi.fn() },
|
||||
onnx: { run: vi.fn() },
|
||||
embed: vi.fn(({ signal }: { signal: AbortSignal }) => {
|
||||
workerSignal = signal
|
||||
return new Promise<number[][]>((resolve) => { finish = resolve })
|
||||
})
|
||||
}
|
||||
const worker = new EmbeddingInferenceWorker({
|
||||
modelDirectory: process.cwd(), engine,
|
||||
port: {
|
||||
postMessage: (message) => transport.receive(message),
|
||||
onMessage: (listener) => {
|
||||
const spy = vi.spyOn(transport, 'postMessage').mockImplementation((message) => {
|
||||
transport.sent.push(message)
|
||||
listener(message)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
}
|
||||
})
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const act = vi.fn(async () => undefined)
|
||||
const unregister = localInferenceService.register('embedding', {
|
||||
snapshot: () => ({ id: 'embedding', name: 'Embedding', engine: 'ONNX', detail: '', ownership: 'managed-process', state: 'running', actions: ['stop', 'restart'] }),
|
||||
act
|
||||
})
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['delayed computation'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(engine.embed).toHaveBeenCalledOnce())
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
expect(workerSignal.aborted).toBe(true)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'restart', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
localInferenceService.cancel(task.id)
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
expect(act).not.toHaveBeenCalled()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
finish([[1, 2]])
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
})
|
||||
await localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })
|
||||
expect(act).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
unregister()
|
||||
finish?.([[1, 2]])
|
||||
await worker.shutdown()
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['abort', 'acknowledgement'], ['abort', 'late result'], ['abort', 'process exit'],
|
||||
['timeout', 'acknowledgement'], ['timeout', 'late result'], ['timeout', 'process exit']
|
||||
] as const)('keeps execution cancelling after %s until %s', async (cause, completion) => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport, timeoutMs: 100 })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['cancelled work'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: cause === 'abort' ? 'AbortError' : 'TimeoutError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
if (cause === 'abort') controller.abort()
|
||||
else await vi.advanceTimersByTimeAsync(100)
|
||||
await rejection
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
if (completion === 'process exit') transport.crash()
|
||||
else transport.receive(response(completion === 'late result'
|
||||
? { type: 'result', requestId: request.requestId, vectors: [[1, 2]] }
|
||||
: { type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({
|
||||
state: cause === 'abort' ? 'cancelled' : 'failed',
|
||||
error: cause === 'abort' ? 'Embedding inference was cancelled' : 'Embedding inference timed out',
|
||||
finishedAt: expect.any(Number)
|
||||
})
|
||||
} finally {
|
||||
await broker.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps rejected work active through an unconfirmed stop until process exit', async () => {
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockRejectedValue(new Error('exit not confirmed'))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['still computing'], 'query')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'failed', finishedAt: expect.any(Number) })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps cancelled requests within the in-flight bound until acknowledgement', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
const callers = Array.from({ length: EMBEDDING_INFERENCE_MAX_IN_FLIGHT }, () =>
|
||||
expect(broker.embed(['pending'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }))
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(EMBEDDING_INFERENCE_MAX_IN_FLIGHT))
|
||||
controller.abort()
|
||||
await Promise.all(callers)
|
||||
await expect(broker.embed(['over capacity'], 'query')).rejects.toMatchObject({ code: 'OVERLOADED' })
|
||||
for (const request of transport.sent.filter((message) => message.type === 'embed')) {
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
}
|
||||
const next = broker.embed(['capacity released'], 'query')
|
||||
await vi.waitFor(() => expect(transport.sent.at(-1)?.type).toBe('embed'))
|
||||
const request = transport.sent.at(-1)!
|
||||
if (request.type !== 'embed') throw new Error('No next embed request was sent')
|
||||
transport.receive(response({ type: 'result', requestId: request.requestId, vectors: [[1, 2]] }))
|
||||
await expect(next).resolves.toEqual([[1, 2]])
|
||||
} finally {
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps execution active after shutdown grace and close timeout until process exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockImplementation(() => new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('exit not confirmed')), 5000)
|
||||
}))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const rejection = expect(broker.embed(['still computing'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
const shutdown = broker.shutdown()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await shutdown
|
||||
expect(transport.close).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
} finally {
|
||||
transport.crash()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a live transport and reports a failed stop instead of claiming stopped', async () => {
|
||||
const transport: EmbeddingInferenceTransport = {
|
||||
postMessage: vi.fn(), onMessage: () => () => undefined, onClose: () => () => undefined,
|
||||
close: vi.fn(async () => { throw new Error('exit not confirmed') })
|
||||
}
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
await broker.start()
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
})
|
||||
|
||||
it('reports startup failures and supports an explicit retry', async () => {
|
||||
const factory = vi.fn().mockRejectedValueOnce(new Error('spawn failed')).mockResolvedValue(new TestTransport())
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
await expect(broker.start()).rejects.toThrow('START_FAILURE')
|
||||
expect(broker.getManagementState()).toBe('error')
|
||||
await broker.start()
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBeUndefined()
|
||||
await broker.stop()
|
||||
})
|
||||
it('management cancellation waits for worker acknowledgement without closing shared transport', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['cancel this'], 'query')
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
localInferenceService.cancel(task.id)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelling')
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelled')
|
||||
await broker.stop()
|
||||
})
|
||||
|
||||
it('stops shared execution, rejects work until explicitly started, and never replays', async () => {
|
||||
const transports: TestTransport[] = []
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => { const transport = new TestTransport(); transports.push(transport); return transport } })
|
||||
const pending = broker.embed(['interrupted'], 'document')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
|
||||
await broker.stop()
|
||||
await rejection
|
||||
expect(transports[0]!.close).toHaveBeenCalledOnce()
|
||||
await expect(broker.embed(['blocked'], 'query')).rejects.toMatchObject({ code: 'CLOSED' })
|
||||
await broker.start()
|
||||
expect(transports).toHaveLength(2)
|
||||
expect(transports[1]!.sent).toEqual([])
|
||||
await broker.stop()
|
||||
})
|
||||
it('uses UUID requests with roles and validates bounded results', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import type { InferenceResources } from '../../shared/local-inference-contracts'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
embeddingInferenceProtocolBase,
|
||||
@@ -15,13 +17,13 @@ const MAX_TIMEOUT_MS = 300_000
|
||||
const DEFAULT_MAX_RESTARTS = 2
|
||||
const MAX_RESTARTS = 10
|
||||
const SHUTDOWN_GRACE_MS = 1_000
|
||||
const MAX_RETIRED_REQUESTS = 256
|
||||
|
||||
export interface EmbeddingInferenceTransport {
|
||||
getResources?(): InferenceResources
|
||||
postMessage(message: EmbeddingInferenceRequest): void
|
||||
onMessage(listener: (message: unknown) => void): () => void
|
||||
onClose(listener: () => void): () => void
|
||||
close(): void
|
||||
close(): void | Promise<void>
|
||||
}
|
||||
|
||||
export type EmbeddingInferenceTransportFactory = () =>
|
||||
@@ -42,6 +44,8 @@ type PendingRequest = {
|
||||
readonly expectedCount: number
|
||||
readonly resolve: (vectors: number[][]) => void
|
||||
readonly reject: (error: Error) => void
|
||||
readonly rejectCaller: (error: Error) => void
|
||||
callerError?: Error
|
||||
readonly timeout: ReturnType<typeof setTimeout>
|
||||
removeAbortListener?: () => void
|
||||
}
|
||||
@@ -62,9 +66,10 @@ export class EmbeddingInferenceBrokerError extends Error {
|
||||
| 'START_FAILURE'
|
||||
| 'ENGINE_FAILURE'
|
||||
| 'INVALID_RESULT'
|
||||
| 'OVERLOADED'
|
||||
| 'OVERLOADED',
|
||||
detail?: string
|
||||
) {
|
||||
super(`Embedding inference failed (${code})`)
|
||||
super(`Embedding inference failed (${code})${detail ? `: ${detail}` : ''}`)
|
||||
this.name = 'EmbeddingInferenceBrokerError'
|
||||
}
|
||||
}
|
||||
@@ -100,13 +105,65 @@ export class EmbeddingInferenceBroker {
|
||||
private readonly timeoutMs: number
|
||||
private readonly maxRestarts: number
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
private readonly retiredRequestIds = new Set<string>()
|
||||
private active?: ActiveTransport
|
||||
private starting?: Promise<ActiveTransport>
|
||||
private restarts = 0
|
||||
private everStarted = false
|
||||
private closed = false
|
||||
private shutdownComplete?: () => void
|
||||
private paused = false
|
||||
private stopping = false
|
||||
private managementError?: string
|
||||
|
||||
getManagementState(): 'stopped' | 'stopping' | 'starting' | 'running' | 'idle' | 'error' {
|
||||
if (this.stopping) return 'stopping'
|
||||
if (this.paused || this.closed) return 'stopped'
|
||||
if (this.starting) return 'starting'
|
||||
if (this.active) return 'running'
|
||||
return this.managementError ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
getManagementError(): string | undefined { return this.managementError }
|
||||
|
||||
getResources(): InferenceResources {
|
||||
return this.active?.transport.getResources?.() ?? {
|
||||
scope: 'unavailable', reason: this.active ? '当前执行端未提供进程指标' : '服务进程未运行'
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.closed) throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
this.paused = false
|
||||
this.restarts = 0
|
||||
try {
|
||||
await this.ensureTransport()
|
||||
this.managementError = undefined
|
||||
} catch (error) {
|
||||
this.managementError = String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.paused = true
|
||||
this.stopping = true
|
||||
const error = new Error('向量执行服务已由用户停止;任务不会自动重放')
|
||||
this.rejectCallers(error)
|
||||
try {
|
||||
if (this.starting) await this.starting.catch(() => undefined)
|
||||
const active = this.active
|
||||
if (!active) return
|
||||
await active.transport.close()
|
||||
this.disposeTransport(active.transport, false)
|
||||
this.rejectAll(error)
|
||||
} catch (error) {
|
||||
this.paused = false
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
constructor(options: EmbeddingInferenceBrokerOptions) {
|
||||
if (typeof options.createTransport !== 'function') {
|
||||
@@ -131,12 +188,29 @@ export class EmbeddingInferenceBroker {
|
||||
texts: readonly string[],
|
||||
role: EmbeddingInferenceRole,
|
||||
signal?: AbortSignal
|
||||
): Promise<number[][]> {
|
||||
const controller = new AbortController()
|
||||
return new Promise<number[][]>((resolve, reject) => {
|
||||
// Track worker execution separately from prompt caller cancellation/timeout.
|
||||
void localInferenceService.run('embedding', role === 'query' ? '向量请求 · 查询' : '向量请求 · 文档',
|
||||
(markCancelling) => this.embedRequest(texts, role, signal, controller.signal, (error) => {
|
||||
markCancelling()
|
||||
reject(error)
|
||||
}),
|
||||
{ cancel: () => controller.abort() }).then(resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
private async embedRequest(
|
||||
texts: readonly string[], role: EmbeddingInferenceRole, signal: AbortSignal | undefined,
|
||||
managementSignal: AbortSignal,
|
||||
rejectCaller: (error: Error) => void
|
||||
): Promise<number[][]> {
|
||||
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
|
||||
if (role !== 'query' && role !== 'document') {
|
||||
throw new TypeError('role must be query or document')
|
||||
}
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -147,7 +221,7 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
|
||||
const active = await this.ensureTransport()
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -166,6 +240,7 @@ export class EmbeddingInferenceBroker {
|
||||
expectedCount: normalizedTexts.length,
|
||||
resolve,
|
||||
reject,
|
||||
rejectCaller,
|
||||
timeout
|
||||
}
|
||||
if (signal) {
|
||||
@@ -178,6 +253,21 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
this.pending.set(requestId, pending)
|
||||
|
||||
const requestCancellation = (): void => {
|
||||
// Management cancellation waits for the worker's result or CANCELLED response.
|
||||
try {
|
||||
active.transport.postMessage({ ...embeddingInferenceProtocolBase(), type: 'cancel', requestId })
|
||||
} catch {
|
||||
this.failTransport(active.transport, new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}
|
||||
}
|
||||
const removeSourceAbort = pending.removeAbortListener
|
||||
pending.removeAbortListener = () => {
|
||||
removeSourceAbort?.()
|
||||
managementSignal.removeEventListener('abort', requestCancellation)
|
||||
}
|
||||
managementSignal.addEventListener('abort', requestCancellation, { once: true })
|
||||
|
||||
try {
|
||||
active.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -186,6 +276,7 @@ export class EmbeddingInferenceBroker {
|
||||
role,
|
||||
texts: normalizedTexts
|
||||
})
|
||||
if (managementSignal.aborted) requestCancellation()
|
||||
} catch {
|
||||
this.failTransport(
|
||||
active.transport,
|
||||
@@ -200,10 +291,7 @@ export class EmbeddingInferenceBroker {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
for (const requestId of this.pending.keys()) {
|
||||
this.retireRequestId(requestId)
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.rejectCallers(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
|
||||
let active = this.active
|
||||
if (!active && this.starting) {
|
||||
@@ -261,7 +349,7 @@ export class EmbeddingInferenceBroker {
|
||||
.then(() => this.createTransport())
|
||||
.then((transport) => {
|
||||
if (this.closed) {
|
||||
transport.close()
|
||||
void Promise.resolve(transport.close()).catch(() => undefined)
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
const active: ActiveTransport = {
|
||||
@@ -272,7 +360,8 @@ export class EmbeddingInferenceBroker {
|
||||
removeCloseListener: transport.onClose(() =>
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED'),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -280,6 +369,7 @@ export class EmbeddingInferenceBroker {
|
||||
return active
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof EmbeddingInferenceBrokerError) {
|
||||
throw error
|
||||
}
|
||||
@@ -317,6 +407,7 @@ export class EmbeddingInferenceBroker {
|
||||
)
|
||||
return
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.shutdownComplete()
|
||||
return
|
||||
}
|
||||
@@ -330,9 +421,6 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
const pending = this.pending.get(message.requestId)
|
||||
if (!pending) {
|
||||
if (this.retiredRequestIds.delete(message.requestId)) {
|
||||
return
|
||||
}
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
|
||||
@@ -345,7 +433,7 @@ export class EmbeddingInferenceBroker {
|
||||
message.requestId,
|
||||
message.code === 'CANCELLED'
|
||||
? abortError()
|
||||
: new EmbeddingInferenceBrokerError(message.code)
|
||||
: new EmbeddingInferenceBrokerError(message.code, message.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -365,11 +453,12 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
private cancelPending(requestId: string, error: Error): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
if (!pending || pending.callerError) {
|
||||
return
|
||||
}
|
||||
this.retireRequestId(requestId)
|
||||
this.finishPending(requestId, error)
|
||||
pending.callerError = error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(error)
|
||||
try {
|
||||
this.active?.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -398,6 +487,7 @@ export class EmbeddingInferenceBroker {
|
||||
this.pending.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.removeAbortListener?.()
|
||||
error = pending.callerError ?? error
|
||||
if (error) {
|
||||
pending.reject(error)
|
||||
} else {
|
||||
@@ -405,26 +495,23 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
}
|
||||
|
||||
private retireRequestId(requestId: string): void {
|
||||
this.retiredRequestIds.add(requestId)
|
||||
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
|
||||
const oldest = this.retiredRequestIds.values().next().value
|
||||
if (typeof oldest === 'string') {
|
||||
this.retiredRequestIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failTransport(
|
||||
transport: EmbeddingInferenceTransport,
|
||||
error: Error
|
||||
error: Error,
|
||||
closed = false
|
||||
): void {
|
||||
if (this.active?.transport !== transport) {
|
||||
return
|
||||
}
|
||||
this.rejectCallers(error)
|
||||
if (closed) {
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
return
|
||||
}
|
||||
this.managementError = error.message
|
||||
this.disposeTransport(transport, true)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
}
|
||||
|
||||
private disposeTransport(
|
||||
@@ -435,15 +522,30 @@ export class EmbeddingInferenceBroker {
|
||||
if (!active || active.transport !== transport) {
|
||||
return
|
||||
}
|
||||
if (close) {
|
||||
try {
|
||||
void Promise.resolve(transport.close()).then(() => {
|
||||
if (this.active !== active) return
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}).catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} catch (error) {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.active = undefined
|
||||
active.removeMessageListener()
|
||||
active.removeCloseListener()
|
||||
if (close) {
|
||||
try {
|
||||
transport.close()
|
||||
} catch {
|
||||
// The process is already unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private rejectCallers(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.callerError ??= error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(pending.callerError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,13 +206,13 @@ export class EmbeddingInferenceWorker {
|
||||
requestId: message.requestId,
|
||||
vectors
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.postError(
|
||||
message.requestId,
|
||||
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
|
||||
controller.signal.aborted
|
||||
? 'Embedding inference was cancelled'
|
||||
: 'Embedding engine failed'
|
||||
: error instanceof Error ? error.message : 'Embedding engine failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app, utilityProcess } from 'electron'
|
||||
import { createInferenceProcessSampler } from '../inference-process-resources'
|
||||
import type { EmbeddingInferenceTransport } from './embedding-inference-broker'
|
||||
|
||||
export function createEmbeddingUtilityTransport(
|
||||
modulePath: string,
|
||||
modelDirectory: string
|
||||
): Promise<EmbeddingInferenceTransport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = utilityProcess.fork(modulePath, [modelDirectory], {
|
||||
serviceName: 'GoodBuddy Embedding Inference',
|
||||
stdio: 'pipe',
|
||||
allowLoadingUnsignedLibraries: false
|
||||
})
|
||||
const startupError = (): void => { reject(new Error('向量进程启动失败')) }
|
||||
child.once('error', startupError)
|
||||
child.once('exit', startupError)
|
||||
child.once('spawn', () => {
|
||||
child.removeListener('error', startupError)
|
||||
child.removeListener('exit', startupError)
|
||||
resolve({
|
||||
getResources: createInferenceProcessSampler(() => child.pid, () => app.getAppMetrics()),
|
||||
postMessage: (message) => child.postMessage(message),
|
||||
onMessage: (listener) => {
|
||||
child.on('message', listener)
|
||||
return () => { child.removeListener('message', listener) }
|
||||
},
|
||||
onClose: (listener) => {
|
||||
let closed = false
|
||||
const notify = (): void => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
listener()
|
||||
}
|
||||
child.on('exit', notify)
|
||||
child.on('error', notify)
|
||||
return () => {
|
||||
child.removeListener('exit', notify)
|
||||
child.removeListener('error', notify)
|
||||
}
|
||||
},
|
||||
close: () => new Promise<void>((resolveClose, rejectClose) => {
|
||||
const finish = (): void => {
|
||||
clearTimeout(timeout)
|
||||
resolveClose()
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('向量进程尚未确认退出,请刷新状态后重试'))
|
||||
}, 5000)
|
||||
child.once('exit', finish)
|
||||
if (!child.kill()) {
|
||||
clearTimeout(timeout)
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('无法停止向量进程,请刷新状态后重试'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { Tokenizer } from '@huggingface/tokenizers'
|
||||
import * as ort from 'onnxruntime-web/wasm'
|
||||
import * as ort from 'onnxruntime-web'
|
||||
import type {
|
||||
EmbeddingInferenceEngine,
|
||||
EmbeddingOnnxRuntime,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocalInferenceService } from './local-inference-service'
|
||||
import type { InferenceService } from '../shared/local-inference-contracts'
|
||||
|
||||
const service: InferenceService = { id: 'embedding', name: 'Embedding', engine: 'ONNX', ownership: 'managed-process', state: 'running', detail: '', actions: ['stop', 'restart'] }
|
||||
|
||||
describe('local inference management', () => {
|
||||
it('does not infer service readiness from an active ASR request', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
registry.register('asr', { snapshot: () => ({ ...service, id: 'asr', state: 'unknown', actions: [] }) })
|
||||
let finish!: () => void
|
||||
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }))
|
||||
expect((await registry.snapshot()).services.find((entry) => entry.id === 'asr')?.state).toBe('unknown')
|
||||
finish()
|
||||
await work
|
||||
})
|
||||
|
||||
it('requires confirmation of every current task and blocks new work during stop', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
let finish!: () => void
|
||||
const work = registry.run('embedding', 'index', () => new Promise<void>((resolve) => { finish = resolve }))
|
||||
const act = vi.fn(async () => {
|
||||
await expect(registry.run('embedding', 'new query', async () => [])).rejects.toThrow('切换')
|
||||
finish()
|
||||
})
|
||||
registry.register('embedding', { snapshot: () => service, act })
|
||||
await expect(registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('重新确认')
|
||||
expect(act).not.toHaveBeenCalled()
|
||||
const task = (await registry.snapshot()).tasks[0]!
|
||||
await registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [task.id] })
|
||||
await work
|
||||
expect(act).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for cancellation acknowledgement and preserves natural completion', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
let finish!: () => void
|
||||
const cancel = vi.fn()
|
||||
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }), { cancel })
|
||||
const id = (await registry.snapshot()).tasks[0]!.id
|
||||
registry.cancel(id)
|
||||
registry.cancel(id)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect((await registry.snapshot()).tasks[0]!.state).toBe('cancelling')
|
||||
finish()
|
||||
await work
|
||||
expect((await registry.snapshot()).tasks[0]!.state).toBe('completed')
|
||||
})
|
||||
|
||||
it('records real failures, bounds history, and isolates unavailable engines', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
registry.register('asr', { snapshot: () => { throw new Error('missing model') } })
|
||||
for (let index = 0; index < 103; index++) await registry.run('embedding', 'query', async () => [])
|
||||
await expect(registry.run('ocr', 'document', async () => { throw new Error('OCR failed') })).rejects.toThrow('OCR failed')
|
||||
const snapshot = await registry.snapshot()
|
||||
expect(snapshot.tasks).toHaveLength(100)
|
||||
expect(snapshot.tasks[0]).toMatchObject({ state: 'failed', error: 'OCR failed' })
|
||||
expect(snapshot.services.find((entry) => entry.id === 'asr')).toMatchObject({ state: 'error', error: 'Error: missing model' })
|
||||
expect(snapshot.services.find((entry) => entry.id === 'tts')).toMatchObject({ state: 'unavailable', actions: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
inferenceActionSchema,
|
||||
type InferenceAction,
|
||||
type InferenceService,
|
||||
type InferenceServiceId,
|
||||
type InferenceTask,
|
||||
type LocalInferenceSnapshot
|
||||
} from '../shared/local-inference-contracts'
|
||||
|
||||
type Adapter = {
|
||||
snapshot: () => InferenceService | Promise<InferenceService>
|
||||
act?: (action: InferenceAction['action']) => Promise<void>
|
||||
}
|
||||
|
||||
const inventory: InferenceService[] = [
|
||||
{ id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker', state: 'unavailable', detail: '语音服务尚未初始化。', actions: [] },
|
||||
{ id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker', state: 'unknown', detail: '模型由文档解析按需加载,空闲 60 秒自动释放。', actions: [] },
|
||||
{ id: 'embedding', name: '向量生成', engine: 'Granite / ONNX', ownership: 'managed-process', state: 'unavailable', detail: '内置向量提供方尚未初始化;请在设置中安装内置模型并启用内置向量连接。外部向量端点由外部管理,本页不控制其进程。', actions: [] },
|
||||
{ id: 'tts', name: '语音合成', engine: '未接入', ownership: 'unavailable', state: 'unavailable', detail: '当前版本没有语音合成执行服务。', actions: [] }
|
||||
]
|
||||
|
||||
export class LocalInferenceService {
|
||||
private readonly adapters = new Map<InferenceServiceId, Adapter>()
|
||||
private readonly tasks = new Map<string, InferenceTask>()
|
||||
private readonly cancellations = new Map<string, () => void>()
|
||||
private readonly operations = new Set<InferenceServiceId>()
|
||||
|
||||
register(id: InferenceServiceId, adapter: Adapter): () => void {
|
||||
this.adapters.set(id, adapter)
|
||||
return () => { if (this.adapters.get(id) === adapter) this.adapters.delete(id) }
|
||||
}
|
||||
|
||||
async snapshot(): Promise<LocalInferenceSnapshot> {
|
||||
const services = await Promise.all(inventory.map(async (fallback) => {
|
||||
try {
|
||||
return await this.adapters.get(fallback.id)?.snapshot() ?? {
|
||||
...fallback, resources: { scope: 'unavailable' as const, reason: fallback.id === 'tts' ? '尚未接入执行服务' : '服务尚未初始化' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { ...fallback, state: 'error' as const, error: String(error), actions: [] }
|
||||
}
|
||||
}))
|
||||
return { services, tasks: [...this.tasks.values()].map((task) => ({ ...task })).reverse() }
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
serviceId: InferenceServiceId,
|
||||
source: string,
|
||||
work: (markCancelling: () => void) => Promise<T>,
|
||||
options: { cancel?: () => void; model?: string; cancelUnavailableReason?: string } = {}
|
||||
): Promise<T> {
|
||||
if (this.operations.has(serviceId)) throw new Error('服务正在切换状态,请稍后重试')
|
||||
const id = randomUUID()
|
||||
const task: InferenceTask = {
|
||||
id, serviceId, source, model: options.model, state: 'running', startedAt: Date.now(),
|
||||
cancelUnavailableReason: options.cancel ? undefined : options.cancelUnavailableReason ?? '该引擎未提供独立任务取消接口。'
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
if (options.cancel) this.cancellations.set(id, options.cancel)
|
||||
try {
|
||||
const result = await work(() => {
|
||||
if (task.finishedAt === undefined) task.state = 'cancelling'
|
||||
})
|
||||
task.state = 'completed'
|
||||
return result
|
||||
} catch (error) {
|
||||
task.state = error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed'
|
||||
task.error = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
task.finishedAt = Date.now()
|
||||
this.cancellations.delete(id)
|
||||
const finished = [...this.tasks.values()].filter((entry) => entry.finishedAt !== undefined)
|
||||
for (const entry of finished.slice(0, Math.max(0, finished.length - 100))) this.tasks.delete(entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
cancel(taskId: string): void {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task || task.finishedAt !== undefined) return
|
||||
if (task.state === 'cancelling') return
|
||||
const cancel = this.cancellations.get(taskId)
|
||||
if (!cancel) throw new Error(task.cancelUnavailableReason)
|
||||
cancel()
|
||||
task.state = 'cancelling'
|
||||
}
|
||||
|
||||
async act(input: InferenceAction): Promise<void> {
|
||||
const request = inferenceActionSchema.parse(input)
|
||||
const adapter = this.adapters.get(request.serviceId)
|
||||
if (!adapter?.act) throw new Error('该服务不支持手动进程控制')
|
||||
if (this.operations.has(request.serviceId)) throw new Error('服务操作正在进行')
|
||||
this.operations.add(request.serviceId)
|
||||
try {
|
||||
const state = await adapter.snapshot()
|
||||
if (!state.actions.includes(request.action)) throw new Error('当前状态不支持此操作')
|
||||
const active = [...this.tasks.values()].filter((task) => task.serviceId === request.serviceId && task.finishedAt === undefined)
|
||||
if (request.action !== 'start' && active.some((task) => !request.confirmedTaskIds.includes(task.id))) {
|
||||
throw new Error('使用此服务的任务已变化,请刷新并重新确认影响')
|
||||
}
|
||||
await adapter.act(request.action)
|
||||
} finally {
|
||||
this.operations.delete(request.serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localInferenceService = new LocalInferenceService()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import {
|
||||
speechTranscriptionInputSchema,
|
||||
speechTranscriptionResultSchema,
|
||||
@@ -190,9 +191,10 @@ export const runSherpaTranscription: SpeechTranscriptionRunner = (
|
||||
signal.removeEventListener('abort', abort)
|
||||
worker.removeAllListeners()
|
||||
if (terminate) {
|
||||
void worker.terminate()
|
||||
void worker.terminate().then(action, reject)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
action()
|
||||
}
|
||||
const abort = (): void =>
|
||||
finish(() => reject(createAbortError()))
|
||||
@@ -265,21 +267,23 @@ export class SpeechTranscriptionService {
|
||||
}
|
||||
const controller = new AbortController()
|
||||
this.active.set(request.requestId, controller)
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
return localInferenceService.run('asr', '语音输入', async () => {
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
}, { cancel: () => controller.abort() })
|
||||
}
|
||||
|
||||
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {
|
||||
|
||||
Reference in New Issue
Block a user