Files
goodbuddy/src/main/application-settings-store.ts
T
mesalogo 7d487f9326 feat: add supervisor workspace and traceable story graph
Extend the existing heartbeat workspace with scoped work reviews, result-specific story graphs, source inspection, and pinned conversation or task feedback. Connect confirmed continuation and local knowledge entity previews through the desktop APIs, and preserve result history and user corrections in schema 43.

Include focused regression tests, an isolated visual fixture, and implementation evidence. Full event-by-event replay, Experiment targets, no-change handling, and cancellation remain incomplete; this commit does not claim complete feature acceptance. Existing automatic plans remain active when the application entry is disabled.

Release note: Review work progress in the Supervisor workspace, trace graph events to saved sources, and open a matching review from pinned conversation or task feedback.
2026-09-23 13:49:13 +08:00

607 lines
20 KiB
TypeScript

import { readFile, realpath, stat } from 'node:fs/promises'
import { delimiter, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import {
applicationSettingsSchema,
applicationSettingsUpdateSchema,
applicationNavigationSchema,
builtInApplicationIds,
defaultApplicationNavigation,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
localToolExecutablePathSchema,
type LocalToolEnvironmentSettings
} from '../shared/local-tool-environment-contracts'
import { inspectLocalToolExecutable } from './local-tool-environment/local-tool-environment'
import { releaseVersionSchema } from '../shared/release-notes-contracts'
import type { SettingsWarning } from '../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
export {
applicationSettingsSchema,
applicationSettingsUpdateSchema
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 12
const legacyStoredApplicationSettingsSchema = z
.object({
version: z.union([z.literal(1), z.literal(2)]),
checkUpdatesOnStartup: z.boolean()
})
.strict()
const versionTwoStoredApplicationSettingsSchema = z
.object({
version: z.literal(2),
checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean()
})
.strict()
const versionThreeStoredApplicationSettingsSchema = z
.object({
version: z.literal(3),
checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNoteCommentMode: applicationSettingsSchema.shape.magicNoteCommentMode
})
.strict()
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(4)
})
.strict()
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(5),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(6),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(7),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
version: z.literal(8),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
// Stored partial orders are upgraded here; public settings writes remain strict.
applicationNavigation: applicationNavigationSchema.extend({
order: z.array(z.enum(builtInApplicationIds))
.refine(ids => new Set(ids).size === ids.length, 'Duplicate application')
.default([])
.transform(ids => [
...(['knowledge', 'heartbeat'] as const).filter(id => !ids.includes(id)),
...ids,
...defaultApplicationNavigation.order.filter(id => id !== 'knowledge' && id !== 'heartbeat' && !ids.includes(id))
]),
pinned: applicationNavigationSchema.shape.pinned.partial()
.default({})
.transform(pinned => ({ ...defaultApplicationNavigation.pinned, ...pinned }))
}).default(defaultApplicationNavigation),
version: z.literal(CURRENT_SETTINGS_VERSION),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
export type LegacyLocalToolEnvironmentPaths = {
nodeExecutablePath?: string
pythonExecutablePath?: string
}
export type LegacyLocalToolEnvironmentResolver =
() => Promise<LegacyLocalToolEnvironmentPaths>
async function resolveExecutable(
kind: 'node' | 'python',
binaryNames: readonly string[]
): Promise<string | undefined> {
const extensions =
process.platform === 'win32'
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter((extension) => /^\.[A-Za-z0-9]+$/u.test(extension))
: ['']
const directories = (
process.env.PATH ??
process.env.Path ??
process.env.path ??
''
)
.split(delimiter)
.map((directory) => directory.trim())
.filter(isAbsolute)
for (const directory of directories) {
for (const binaryName of binaryNames) {
const names =
process.platform === 'win32' && !extname(binaryName)
? extensions.map((extension) => `${binaryName}${extension}`)
: [binaryName]
for (const name of names) {
try {
const executablePath = await realpath(join(directory, name))
const metadata = await stat(executablePath)
if (
metadata.isFile() &&
localToolExecutablePathSchema.safeParse(executablePath).success
) {
const candidate = await inspectLocalToolExecutable(
kind,
executablePath,
{ baseEnvironment: process.env }
)
if (candidate) {
return candidate.executablePath
}
}
} catch {
// Continue through PATH candidates.
}
}
}
}
return undefined
}
export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentResolver =
async () => {
const [nodeExecutablePath, pythonExecutablePath] = await Promise.all([
resolveExecutable('node', ['node']),
resolveExecutable(
'python',
process.platform === 'win32'
? ['python', 'python3']
: ['python3', 'python']
)
])
return {
...(nodeExecutablePath ? { nodeExecutablePath } : {}),
...(pythonExecutablePath ? { pythonExecutablePath } : {})
}
}
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
magicNotesEnabled: true,
heartbeatEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
magicNoteCanvasPageCount: 1
}
export class ApplicationSettingsStore {
private settings?: StoredApplicationSettings
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'
| 'heartbeatEnabled'
| 'magicNoteCanvasPageCount'
> & {
conversationHtmlRenderingEnabled?: boolean
magicNoteCanvasPageCount?: number
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
executablePath: string | undefined
): LocalToolEnvironmentSettings['node'] =>
executablePath &&
localToolExecutablePathSchema.safeParse(executablePath).success
? { source: 'custom', executablePath }
: { source: 'managed' }
const next: StoredApplicationSettings = {
...defaultApplicationSettings,
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
python: selection(resolved.pythonExecutablePath),
artifactDownloadSource: 'native'
}
}
await this.persist(next)
return next
}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
this.filePath,
'Application settings are corrupt and could not be isolated'
)
}
private async loadStored(): Promise<StoredApplicationSettings> {
if (this.settings) {
return this.settings
}
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredApplicationSettings> {
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
)
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) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
return this.migrateLegacy(versionNineResult.data)
}
const versionEightResult =
versionEightStoredApplicationSettingsSchema.safeParse(parsed)
if (versionEightResult.success) {
return this.migrateLegacy({
...versionEightResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false
})
}
const versionSevenResult =
versionSevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSevenResult.success) {
return this.migrateLegacy({
...versionSevenResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionSixResult =
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSixResult.success) {
return this.migrateLegacy({
...versionSixResult.data,
version: CURRENT_SETTINGS_VERSION,
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionFiveResult =
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFiveResult.success) {
return this.migrateLegacy({
...versionFiveResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionFourResult =
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFourResult.success) {
return this.migrateLegacy({
...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
lastSeenReleaseNotesVersion: null
})
}
const versionThreeResult =
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
if (versionThreeResult.success) {
return this.migrateLegacy({
...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
const versionTwoResult =
versionTwoStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTwoResult.success) {
return this.migrateLegacy({
...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
const legacyResult =
legacyStoredApplicationSettingsSchema.safeParse(parsed)
if (legacyResult.success) {
return this.migrateLegacy({
version: CURRENT_SETTINGS_VERSION,
checkUpdatesOnStartup: legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
}
this.settings = result.data
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Application settings could not be read', {
cause: error
})
}
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
}
return this.settings
}
async get(): Promise<ApplicationSettings> {
const stored = await this.loadStored()
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
applicationNavigation: stored.applicationNavigation,
localInferenceEnabled: stored.localInferenceEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
heartbeatEnabled: stored.heartbeatEnabled,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat,
magicNoteCanvasPageCount: stored.magicNoteCanvasPageCount,
...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {})
}
}
async getLastSeenReleaseNotesVersion(): Promise<string | null> {
return (await this.loadStored()).lastSeenReleaseNotesVersion
}
private async persist(next: StoredApplicationSettings): Promise<void> {
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
}
update(input: unknown): Promise<ApplicationSettings> {
const operation = this.updateQueue.then(async () => {
const updates = applicationSettingsUpdateSchema.parse(input)
const current = await this.loadStored()
const next: StoredApplicationSettings = {
...current,
...updates,
version: CURRENT_SETTINGS_VERSION
}
await this.persist(next)
this.warnings = []
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
applicationNavigation: next.applicationNavigation,
localInferenceEnabled: next.localInferenceEnabled,
magicNotesEnabled: next.magicNotesEnabled,
heartbeatEnabled: next.heartbeatEnabled,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat,
magicNoteCanvasPageCount: next.magicNoteCanvasPageCount
}
for (const listener of this.listeners) listener(settings)
return settings
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
setLastSeenReleaseNotesVersion(version: unknown): Promise<void> {
const operation = this.updateQueue.then(async () => {
const parsedVersion = releaseVersionSchema.parse(version)
const current = await this.loadStored()
if (current.lastSeenReleaseNotesVersion === parsedVersion) {
return
}
await this.persist({
...current,
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: parsedVersion
})
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}