Compare commits
9 Commits
codenomad/
...
v0.10.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
5067db3dd0 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d |
@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed?.preferences?.listeningMode
|
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.53"
|
"@opencode-ai/plugin": "1.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -183,9 +182,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfig = ConfigFile
|
export type SettingsOwner = string
|
||||||
export type AppConfigResponse = AppConfig
|
export type SettingsBucket = Record<string, unknown>
|
||||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -214,8 +213,8 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "config.appChanged"
|
| "storage.configChanged"
|
||||||
| "config.binariesChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
@@ -226,8 +225,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import {
|
|
||||||
BinaryCreateRequest,
|
|
||||||
BinaryRecord,
|
|
||||||
BinaryUpdateRequest,
|
|
||||||
BinaryValidationResult,
|
|
||||||
} from "../api-types"
|
|
||||||
import { spawnSync } from "child_process"
|
|
||||||
import { ConfigStore } from "./store"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import type { ConfigFile } from "./schema"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
|
||||||
|
|
||||||
export class BinaryRegistry {
|
|
||||||
constructor(
|
|
||||||
private readonly configStore: ConfigStore,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
list(): BinaryRecord[] {
|
|
||||||
return this.mapRecords()
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveDefault(): BinaryRecord {
|
|
||||||
const binaries = this.mapRecords()
|
|
||||||
if (binaries.length === 0) {
|
|
||||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
|
||||||
return this.buildFallbackRecord("opencode")
|
|
||||||
}
|
|
||||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
create(request: BinaryCreateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
|
||||||
const entry = {
|
|
||||||
path: request.path,
|
|
||||||
version: undefined,
|
|
||||||
lastUsed: Date.now(),
|
|
||||||
label: request.label,
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
|
||||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
|
||||||
|
|
||||||
if (request.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = request.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(request.path)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
|
||||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updates.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = id
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(id)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(id: string) {
|
|
||||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
|
||||||
nextConfig.opencodeBinaries = remaining
|
|
||||||
|
|
||||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
this.emitChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePath(path: string): BinaryValidationResult {
|
|
||||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
|
||||||
return this.validateRecord({
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
|
||||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapRecords(): BinaryRecord[] {
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
|
||||||
id: binary.path,
|
|
||||||
path: binary.path,
|
|
||||||
label: binary.label ?? this.prettyLabel(binary.path),
|
|
||||||
version: binary.version,
|
|
||||||
isDefault: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
|
||||||
|
|
||||||
const annotated = configuredBinaries.map((binary) => ({
|
|
||||||
...binary,
|
|
||||||
isDefault: binary.path === defaultPath,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
|
||||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
return annotated
|
|
||||||
}
|
|
||||||
|
|
||||||
private getById(id: string): BinaryRecord {
|
|
||||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitChange() {
|
|
||||||
this.logger.debug("Emitting binaries changed event")
|
|
||||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
|
||||||
const inputPath = record.path
|
|
||||||
if (!inputPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(inputPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = (result.stdout ?? "").trim()
|
|
||||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
|
||||||
const normalized = firstLine?.trim()
|
|
||||||
|
|
||||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
|
|
||||||
return { valid: true, version }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFallbackRecord(path: string): BinaryRecord {
|
|
||||||
return {
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private prettyLabel(path: string) {
|
|
||||||
const parts = path.split(/[\\/]/)
|
|
||||||
const last = parts[parts.length - 1] || path
|
|
||||||
return last || path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import {
|
|
||||||
ConfigFile,
|
|
||||||
ConfigFileSchema,
|
|
||||||
ConfigYamlSchema,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
DEFAULT_CONFIG_YAML,
|
|
||||||
DEFAULT_STATE,
|
|
||||||
StateFile,
|
|
||||||
StateFileSchema,
|
|
||||||
} from "./schema"
|
|
||||||
import type { ConfigLocation } from "./location"
|
|
||||||
|
|
||||||
export class ConfigStore {
|
|
||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
|
||||||
private state: StateFile = DEFAULT_STATE
|
|
||||||
private loaded = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly location: ConfigLocation,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
load(): ConfigFile {
|
|
||||||
if (this.loaded) {
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
const legacyJsonPath = this.location.legacyJsonPath
|
|
||||||
|
|
||||||
if (fs.existsSync(configYamlPath)) {
|
|
||||||
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
|
||||||
const stateDoc = fs.existsSync(stateYamlPath)
|
|
||||||
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
|
||||||
: DEFAULT_STATE
|
|
||||||
|
|
||||||
this.state = stateDoc
|
|
||||||
this.cache = this.mergeDocs(configDoc, stateDoc)
|
|
||||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
|
||||||
} else if (fs.existsSync(legacyJsonPath)) {
|
|
||||||
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
|
||||||
this.state = migrated.state
|
|
||||||
this.cache = migrated.config
|
|
||||||
} else {
|
|
||||||
// Fresh install: write defaults.
|
|
||||||
this.state = DEFAULT_STATE
|
|
||||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
|
||||||
this.persist()
|
|
||||||
this.logger.debug(
|
|
||||||
{ configYamlPath, stateYamlPath },
|
|
||||||
"No config files found, created default YAML config/state",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
|
||||||
this.state = DEFAULT_STATE
|
|
||||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): ConfigFile {
|
|
||||||
return this.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
replace(config: ConfigFile) {
|
|
||||||
const validated = ConfigFileSchema.parse(config)
|
|
||||||
this.commit(validated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a merge-patch update to the current config.
|
|
||||||
* - Missing keys are preserved.
|
|
||||||
* - Object values are merged recursively.
|
|
||||||
* - Explicit `null` deletes keys.
|
|
||||||
* - Arrays are replaced.
|
|
||||||
*/
|
|
||||||
mergePatch(patch: unknown) {
|
|
||||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
||||||
throw new Error("Config patch must be a JSON object")
|
|
||||||
}
|
|
||||||
const current = this.get()
|
|
||||||
const next = applyMergePatch(current as any, patch as any)
|
|
||||||
const validated = ConfigFileSchema.parse(next)
|
|
||||||
this.commit(validated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private commit(next: ConfigFile) {
|
|
||||||
this.cache = next
|
|
||||||
this.loaded = true
|
|
||||||
this.state = {
|
|
||||||
...this.state,
|
|
||||||
recentFolders: next.recentFolders,
|
|
||||||
}
|
|
||||||
this.persist()
|
|
||||||
const published = Boolean(this.eventBus)
|
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
|
||||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
|
||||||
this.logger.trace({ config: this.cache }, "Config payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
private persist() {
|
|
||||||
try {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
|
|
||||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
|
||||||
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
|
||||||
|
|
||||||
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
|
||||||
const stateYaml = stringifyYaml(this.state as any)
|
|
||||||
|
|
||||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
|
||||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
|
||||||
|
|
||||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to persist config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
|
||||||
const merged = {
|
|
||||||
...(configDoc as any),
|
|
||||||
// State wins for recent folders.
|
|
||||||
recentFolders: stateDoc.recentFolders ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConfigFileSchema.parse(merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readYamlFile<T>(
|
|
||||||
filePath: string,
|
|
||||||
fallback: T,
|
|
||||||
schema: { parse: (value: unknown) => T },
|
|
||||||
label: string,
|
|
||||||
): T {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
|
||||||
const parsed = parseYaml(content)
|
|
||||||
return schema.parse(parsed ?? {})
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
|
|
||||||
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
|
||||||
const parsed = JSON.parse(content)
|
|
||||||
const legacy = ConfigFileSchema.parse(parsed)
|
|
||||||
|
|
||||||
const state: StateFile = StateFileSchema.parse({
|
|
||||||
...DEFAULT_STATE,
|
|
||||||
recentFolders: legacy.recentFolders ?? [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
|
||||||
|
|
||||||
// Persist YAML docs first, then move legacy aside.
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
|
||||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
|
||||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
|
||||||
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bakPath = pickBackupPath(legacyJsonPath)
|
|
||||||
fs.renameSync(legacyJsonPath, bakPath)
|
|
||||||
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config: merged, state }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTrailingNewline(content: string): string {
|
|
||||||
if (!content) return "\n"
|
|
||||||
return content.endsWith("\n") ? content : `${content}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
|
||||||
const clone: Record<string, unknown> = { ...(config as any) }
|
|
||||||
delete clone.recentFolders
|
|
||||||
return clone as any
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== "object") return false
|
|
||||||
if (Array.isArray(value)) return false
|
|
||||||
const proto = Object.getPrototypeOf(value)
|
|
||||||
return proto === Object.prototype || proto === null
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMergePatch(current: any, patch: any): any {
|
|
||||||
// RFC 7396-ish merge patch with explicit null deletes.
|
|
||||||
if (!isPlainObject(patch)) {
|
|
||||||
return patch
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = isPlainObject(current) ? { ...current } : {}
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
if (value === null) {
|
|
||||||
delete base[key]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainObject(value) && isPlainObject(base[key])) {
|
|
||||||
base[key] = applyMergePatch(base[key], value)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrays and scalars replace.
|
|
||||||
base[key] = value
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBackupPath(legacyJsonPath: string): string {
|
|
||||||
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
|
||||||
const preferred = `${base}.json.bak`
|
|
||||||
if (!fs.existsSync(preferred)) {
|
|
||||||
return preferred
|
|
||||||
}
|
|
||||||
return `${base}.json.bak.${Date.now()}`
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("config.appChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("config.appChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
import { ConfigStore } from "./config/store"
|
|
||||||
import { resolveConfigLocation } from "./config/location"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { BinaryRegistry } from "./config/binaries"
|
import { SettingsService } from "./settings/service"
|
||||||
|
import { BinaryResolver } from "./settings/binaries"
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
@@ -291,21 +291,12 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||||
|
const binaryResolver = new BinaryResolver(settings)
|
||||||
// Eagerly load config at boot so migrations run immediately
|
|
||||||
// (instead of waiting for the first /api/config request).
|
|
||||||
try {
|
|
||||||
configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
|
||||||
}
|
|
||||||
|
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
binaryResolver,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.localUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
@@ -392,8 +383,7 @@ async function main() {
|
|||||||
defaultPort: options.httpPort,
|
defaultPort: options.httpPort,
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
@@ -413,8 +403,7 @@ async function main() {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
httpsOptions: tlsResolution?.httpsOptions,
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import type { Logger } from "../logger"
|
|||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerConfigRoutes } from "./routes/config"
|
import { registerSettingsRoutes } from "./routes/settings"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
@@ -37,8 +36,7 @@ interface HttpServerDeps {
|
|||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { ConfigStore } from "../../config/store"
|
|
||||||
import { BinaryRegistry } from "../../config/binaries"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
|
||||||
configStore: ConfigStore
|
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
}
|
|
||||||
|
|
||||||
const BinaryCreateSchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
label: z.string().optional(),
|
|
||||||
makeDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const BinaryUpdateSchema = z.object({
|
|
||||||
label: z.string().optional(),
|
|
||||||
makeDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const BinaryValidateSchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|
||||||
app.get("/api/config/app", async () => deps.configStore.get())
|
|
||||||
|
|
||||||
app.put("/api/config/app", async (request, reply) => {
|
|
||||||
// Backwards compatible: treat PUT as a merge-patch update.
|
|
||||||
try {
|
|
||||||
deps.configStore.mergePatch(request.body ?? {})
|
|
||||||
return deps.configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch("/api/config/app", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
deps.configStore.mergePatch(request.body ?? {})
|
|
||||||
return deps.configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/config/binaries", async () => {
|
|
||||||
return { binaries: deps.binaryRegistry.list() }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/config/binaries", async (request, reply) => {
|
|
||||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
|
||||||
const binary = deps.binaryRegistry.create(body)
|
|
||||||
reply.code(201)
|
|
||||||
return { binary }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
|
||||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
|
||||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
|
||||||
return { binary }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
|
||||||
deps.binaryRegistry.remove(request.params.id)
|
|
||||||
reply.code(204)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/config/binaries/validate", async (request) => {
|
|
||||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
|
||||||
return deps.binaryRegistry.validatePath(body.path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
110
packages/server/src/server/routes/settings.ts
Normal file
110
packages/server/src/server/routes/settings.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { buildSpawnSpec } from "../../workspaces/runtime"
|
||||||
|
import type { SettingsService } from "../../settings/service"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
settings: SettingsService
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidateBinarySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||||
|
try {
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = (result.stdout ?? "").trim()
|
||||||
|
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||||
|
const normalized = firstLine?.trim()
|
||||||
|
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
return { valid: true, version }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
// Full-document access
|
||||||
|
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
|
||||||
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||||
|
return deps.settings.getOwner("config", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
|
||||||
|
app.patch("/api/storage/state", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchDoc("state", request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
|
||||||
|
return deps.settings.getOwner("state", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Binary validation helper (used by UI when adding binaries)
|
||||||
|
app.post("/api/storage/binaries/validate", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ValidateBinarySchema.parse(request.body ?? {})
|
||||||
|
return validateBinaryPath(body.path)
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to validate binary")
|
||||||
|
reply.code(400)
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
55
packages/server/src/settings/binaries.ts
Normal file
55
packages/server/src/settings/binaries.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { SettingsService } from "./service"
|
||||||
|
|
||||||
|
export interface OpenCodeBinaryEntry {
|
||||||
|
path: string
|
||||||
|
version?: string
|
||||||
|
lastUsed?: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBinary {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyLabel(p: string): string {
|
||||||
|
const parts = p.split(/[\\/]/)
|
||||||
|
const last = parts[parts.length - 1] || p
|
||||||
|
return last || p
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
||||||
|
const ui = settings.getOwner("state", "ui")
|
||||||
|
const list = (ui as any)?.opencodeBinaries
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
||||||
|
const server = settings.getOwner("config", "server")
|
||||||
|
const value = (server as any)?.opencodeBinary
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BinaryResolver {
|
||||||
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
|
list(): OpenCodeBinaryEntry[] {
|
||||||
|
return readUiBinaries(this.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefault(): ResolvedBinary {
|
||||||
|
const binaries = this.list()
|
||||||
|
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||||
|
const fallback = binaries[0]?.path
|
||||||
|
const path = configuredDefault ?? fallback ?? "opencode"
|
||||||
|
|
||||||
|
const entry = binaries.find((b) => b.path === path)
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
label: entry?.label ?? prettyLabel(path),
|
||||||
|
version: entry?.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/server/src/settings/merge-patch.ts
Normal file
39
packages/server/src/settings/merge-patch.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
type PlainObject = Record<string, unknown>
|
||||||
|
|
||||||
|
export function isPlainObject(value: unknown): value is PlainObject {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
* - Objects merge recursively
|
||||||
|
* - Arrays/scalars replace
|
||||||
|
* - null deletes keys
|
||||||
|
*/
|
||||||
|
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = base[key]
|
||||||
|
if (isPlainObject(value) && isPlainObject(existing)) {
|
||||||
|
base[key] = applyMergePatch(existing, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
269
packages/server/src/settings/migrate.ts
Normal file
269
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
type Doc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return parseYaml(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadJson(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(doc as any)
|
||||||
|
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(filePath: string): string {
|
||||||
|
const preferred = `${filePath}.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${filePath}.bak.${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(value: unknown): Doc {
|
||||||
|
return isPlainObject(value) ? (value as Doc) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
// Heuristic: owner-bucket docs have at least one of these roots.
|
||||||
|
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyConfig(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyState(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
||||||
|
const out: Doc = {}
|
||||||
|
for (const [k, v] of Object.entries(source)) {
|
||||||
|
if (keys.has(k)) continue
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
||||||
|
const cfg = normalizeDoc(legacyConfig)
|
||||||
|
const st = normalizeDoc(legacyState)
|
||||||
|
|
||||||
|
const outConfig: Doc = {}
|
||||||
|
const outState: Doc = {}
|
||||||
|
|
||||||
|
const uiConfig: Doc = {}
|
||||||
|
const uiSettings: Doc = {}
|
||||||
|
const serverConfig: Doc = {}
|
||||||
|
const uiState: Doc = {}
|
||||||
|
|
||||||
|
// theme -> config.ui.theme
|
||||||
|
if (typeof cfg.theme === "string") {
|
||||||
|
uiConfig.theme = cfg.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = normalizeDoc(cfg.preferences)
|
||||||
|
if (Object.keys(preferences).length > 0) {
|
||||||
|
// Server-owned stable keys
|
||||||
|
const envVars = preferences.environmentVariables
|
||||||
|
if (isPlainObject(envVars)) {
|
||||||
|
serverConfig.environmentVariables = envVars
|
||||||
|
}
|
||||||
|
const listeningMode = preferences.listeningMode
|
||||||
|
if (typeof listeningMode === "string") {
|
||||||
|
serverConfig.listeningMode = listeningMode
|
||||||
|
}
|
||||||
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
|
if (typeof lastUsedBinary === "string") {
|
||||||
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-owned state keys (drop preferences)
|
||||||
|
const modelRecents = preferences.modelRecents
|
||||||
|
const modelFavorites = preferences.modelFavorites
|
||||||
|
const modelThinkingSelections = preferences.modelThinkingSelections
|
||||||
|
|
||||||
|
const models: Doc = {}
|
||||||
|
if (Array.isArray(modelRecents)) {
|
||||||
|
models.recents = modelRecents
|
||||||
|
}
|
||||||
|
if (Array.isArray(modelFavorites)) {
|
||||||
|
models.favorites = modelFavorites
|
||||||
|
}
|
||||||
|
if (isPlainObject(modelThinkingSelections)) {
|
||||||
|
models.thinkingSelections = modelThinkingSelections
|
||||||
|
}
|
||||||
|
if (Object.keys(models).length > 0) {
|
||||||
|
uiState.models = models
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining preferences are treated as stable UI settings.
|
||||||
|
const moved = new Set([
|
||||||
|
"environmentVariables",
|
||||||
|
"listeningMode",
|
||||||
|
"lastUsedBinary",
|
||||||
|
"modelRecents",
|
||||||
|
"modelFavorites",
|
||||||
|
"modelThinkingSelections",
|
||||||
|
])
|
||||||
|
Object.assign(uiSettings, omitKeys(preferences, moved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentFolders lives in legacy state (yaml) or legacy config.json
|
||||||
|
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
||||||
|
if (Array.isArray(recentFolders)) {
|
||||||
|
uiState.recentFolders = recentFolders
|
||||||
|
}
|
||||||
|
|
||||||
|
// opencodeBinaries -> state.ui.opencodeBinaries
|
||||||
|
if (Array.isArray(cfg.opencodeBinaries)) {
|
||||||
|
uiState.opencodeBinaries = cfg.opencodeBinaries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiSettings).length > 0) {
|
||||||
|
uiConfig.settings = uiSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiConfig).length > 0) {
|
||||||
|
outConfig.ui = uiConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(serverConfig).length > 0) {
|
||||||
|
outConfig.server = serverConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(uiState).length > 0) {
|
||||||
|
outState.ui = uiState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown top-level keys -> legacy.unknown
|
||||||
|
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
||||||
|
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
||||||
|
if (Object.keys(unknownConfig).length > 0) {
|
||||||
|
outConfig.legacy = { unknown: unknownConfig }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownStateKeys = new Set(["recentFolders"])
|
||||||
|
const unknownState = omitKeys(st, knownStateKeys)
|
||||||
|
if (Object.keys(unknownState).length > 0) {
|
||||||
|
outState.legacy = { unknown: unknownState }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: outConfig, state: outState }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate older config/state layouts into owner-bucket YAML docs.
|
||||||
|
*
|
||||||
|
* Legacy inputs supported:
|
||||||
|
* - config.yaml with { preferences, opencodeBinaries, theme }
|
||||||
|
* - state.yaml with { recentFolders }
|
||||||
|
* - legacy config.json with full ConfigFile schema
|
||||||
|
*/
|
||||||
|
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
||||||
|
const configYamlPath = location.configYamlPath
|
||||||
|
const stateYamlPath = location.stateYamlPath
|
||||||
|
const legacyJsonPath = location.legacyJsonPath
|
||||||
|
|
||||||
|
const configExists = fs.existsSync(configYamlPath)
|
||||||
|
const stateExists = fs.existsSync(stateYamlPath)
|
||||||
|
|
||||||
|
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
||||||
|
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
||||||
|
|
||||||
|
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
||||||
|
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
||||||
|
|
||||||
|
if (configIsNew && stateIsNew) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
||||||
|
|
||||||
|
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
||||||
|
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
||||||
|
|
||||||
|
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
||||||
|
// Either fresh install or partially written docs; let stores create on first write.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
||||||
|
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
||||||
|
|
||||||
|
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(location.baseDir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup legacy files before rewriting.
|
||||||
|
if (configExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(configYamlPath)
|
||||||
|
fs.renameSync(configYamlPath, bak)
|
||||||
|
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(stateYamlPath)
|
||||||
|
fs.renameSync(stateYamlPath, bak)
|
||||||
|
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMigrateFromJson) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bak)
|
||||||
|
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeYaml(configYamlPath, config, logger)
|
||||||
|
writeYaml(stateYamlPath, state, logger)
|
||||||
|
|
||||||
|
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
||||||
|
}
|
||||||
55
packages/server/src/settings/service.ts
Normal file
55
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
|
|
||||||
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
export class SettingsService {
|
||||||
|
private readonly configStore: YamlDocStore
|
||||||
|
private readonly stateStore: YamlDocStore
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly location: ConfigLocation,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
migrateSettingsLayout(location, logger)
|
||||||
|
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||||
|
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
|
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
|
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||||
|
this.publish(kind, "*")
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
|
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
|
const updated =
|
||||||
|
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||||
|
this.publish(kind, owner, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||||
|
if (!this.eventBus) return
|
||||||
|
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||||
|
const payload: WorkspaceEventPayload = {
|
||||||
|
type,
|
||||||
|
owner,
|
||||||
|
value: value ?? this.getOwner(kind, owner),
|
||||||
|
} as any
|
||||||
|
this.eventBus.publish(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(input: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YamlDocStore {
|
||||||
|
private cache: SettingsDoc = {}
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly filePath: string,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): SettingsDoc {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.filePath)) {
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
this.cache = normalizeDoc(parsed)
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): SettingsDoc {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(next: unknown): SettingsDoc {
|
||||||
|
const normalized = normalizeDoc(next)
|
||||||
|
this.cache = normalized
|
||||||
|
this.loaded = true
|
||||||
|
this.persist()
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatch(patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current, patch)
|
||||||
|
return this.replace(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(owner: string): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const value = (doc as any)?.[owner]
|
||||||
|
return normalizeDoc(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextDoc[owner] as SettingsDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const doc = this.get()
|
||||||
|
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
||||||
|
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(this.cache as any)
|
||||||
|
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import type { BinaryResolver } from "../settings/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
|||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
binaryResolver: BinaryResolver
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
@@ -86,7 +86,7 @@ export class WorkspaceManager {
|
|||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryResolver.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
@@ -118,8 +118,9 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const envVars = (serverConfig as any)?.environmentVariables
|
||||||
|
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||||
|
|
||||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
const opencodePassword = generateOpencodeServerPassword()
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
|
|||||||
@@ -140,9 +140,16 @@ struct PreferencesConfig {
|
|||||||
listening_mode: Option<String>,
|
listening_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ServerConfig {
|
||||||
|
#[serde(rename = "listeningMode")]
|
||||||
|
listening_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
preferences: Option<PreferencesConfig>,
|
preferences: Option<PreferencesConfig>,
|
||||||
|
server: Option<ServerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
@@ -188,11 +195,18 @@ fn resolve_listening_mode() -> String {
|
|||||||
|
|
||||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
if let Some(mode) = config
|
let mode = config
|
||||||
.preferences
|
.server
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
{
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(mode) = mode {
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -206,11 +220,17 @@ fn resolve_listening_mode() -> String {
|
|||||||
// Legacy fallback.
|
// Legacy fallback.
|
||||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
if let Some(mode) = config
|
let mode = config
|
||||||
.preferences
|
.server
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
{
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
if let Some(mode) = mode {
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const App: Component = () => {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
|
serverSettings,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
toggleKeyboardShortcutHints,
|
||||||
@@ -185,7 +186,7 @@ const App: Component = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
|||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
serverSettings,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateEnvironmentVariables,
|
updateEnvironmentVariables,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
||||||
const [newKey, setNewKey] = createSignal("")
|
const [newKey, setNewKey] = createSignal("")
|
||||||
const [newValue, setNewValue] = createSignal("")
|
const [newValue, setNewValue] = createSignal("")
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ interface FolderSelectionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
@@ -54,7 +54,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = preferences().lastUsedBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
if (!lastUsed) return
|
if (!lastUsed) return
|
||||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -198,6 +198,16 @@ interface MessageContentItemProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedPartType(part: unknown): boolean {
|
||||||
|
const type = (part as any)?.type
|
||||||
|
// Ignore part types the UI does not support rendering yet.
|
||||||
|
return !(typeof type === "string" && type === "patch")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentPartType(type: unknown): boolean {
|
||||||
|
return type === "text" || type === "file"
|
||||||
|
}
|
||||||
|
|
||||||
function MessageContentItem(props: MessageContentItemProps) {
|
function MessageContentItem(props: MessageContentItemProps) {
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
@@ -222,15 +232,9 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (
|
if (!isSupportedPartType(part)) continue
|
||||||
part.type === "tool" ||
|
|
||||||
part.type === "reasoning" ||
|
if (!isContentPartType((part as any).type)) break
|
||||||
part.type === "compaction" ||
|
|
||||||
part.type === "step-start" ||
|
|
||||||
part.type === "step-finish"
|
|
||||||
) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
resolved.push(part)
|
resolved.push(part)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,15 +260,9 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (
|
if (!isSupportedPartType(part)) continue
|
||||||
part.type === "tool" ||
|
|
||||||
part.type === "reasoning" ||
|
if (!isContentPartType((part as any).type)) continue
|
||||||
part.type === "compaction" ||
|
|
||||||
part.type === "step-start" ||
|
|
||||||
part.type === "step-finish"
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (partHasRenderableText(part)) {
|
if (partHasRenderableText(part)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -549,6 +547,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderedParts.forEach((part, partIndex) => {
|
orderedParts.forEach((part, partIndex) => {
|
||||||
|
if (!isSupportedPartType(part)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
flushContent()
|
flushContent()
|
||||||
const partId = part.id
|
const partId = part.id
|
||||||
|
|||||||
@@ -151,7 +151,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
const timeInfo = info?.time as { created: number; end?: number } | undefined
|
||||||
|
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
|
|||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
|
|
||||||
const favoriteKeySet = createMemo(() => {
|
const favoriteKeySet = createMemo(() => {
|
||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
for (const item of preferences().modelFavorites ?? []) {
|
for (const item of uiState().models.favorites ?? []) {
|
||||||
if (item.providerId && item.modelId) {
|
if (item.providerId && item.modelId) {
|
||||||
result.add(`${item.providerId}/${item.modelId}`)
|
result.add(`${item.providerId}/${item.modelId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
removeOpenCodeBinary,
|
removeOpenCodeBinary,
|
||||||
preferences,
|
serverSettings,
|
||||||
updatePreferences,
|
updateLastUsedBinary,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [customPath, setCustomPath] = createSignal("")
|
const [customPath, setCustomPath] = createSignal("")
|
||||||
const [validating, setValidating] = createSignal(false)
|
const [validating, setValidating] = createSignal(false)
|
||||||
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
const lastUsedBinary = () => serverSettings().opencodeBinary
|
||||||
|
|
||||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
addOpenCodeBinary(path, validation.version)
|
addOpenCodeBinary(path, validation.version)
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updatePreferences({ lastUsedBinary: path })
|
updateLastUsedBinary(path)
|
||||||
setCustomPath("")
|
setCustomPath("")
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (path === props.selectedBinary) return
|
if (path === props.selectedBinary) return
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updatePreferences({ lastUsedBinary: path })
|
updateLastUsedBinary(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinary(path: string, event: Event) {
|
function handleRemoveBinary(path: string, event: Event) {
|
||||||
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
if (props.selectedBinary === path) {
|
if (props.selectedBinary === path) {
|
||||||
props.onBinaryChange("opencode")
|
props.onBinaryChange("opencode")
|
||||||
updatePreferences({ lastUsedBinary: "opencode" })
|
updateLastUsedBinary("opencode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
|
|||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
import { preferences, setListeningMode } from "../stores/preferences"
|
import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AppConfig,
|
|
||||||
BackgroundProcess,
|
BackgroundProcess,
|
||||||
BackgroundProcessListResponse,
|
BackgroundProcessListResponse,
|
||||||
BackgroundProcessOutputResponse,
|
BackgroundProcessOutputResponse,
|
||||||
BinaryCreateRequest,
|
|
||||||
BinaryListResponse,
|
|
||||||
BinaryUpdateRequest,
|
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
@@ -214,37 +210,27 @@ export const serverApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchConfig(): Promise<AppConfig> {
|
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
return request<AppConfig>("/api/config/app")
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||||
},
|
},
|
||||||
updateConfig(payload: AppConfig): Promise<AppConfig> {
|
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
||||||
return request<AppConfig>("/api/config/app", {
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
listBinaries(): Promise<BinaryListResponse> {
|
|
||||||
return request<BinaryListResponse>("/api/config/binaries")
|
|
||||||
},
|
|
||||||
createBinary(payload: BinaryCreateRequest) {
|
|
||||||
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateBinary(id: string, updates: BinaryUpdateRequest) {
|
|
||||||
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(patch ?? {}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
|
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
|
||||||
|
},
|
||||||
|
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
||||||
|
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(patch ?? {}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBinary(id: string): Promise<void> {
|
|
||||||
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
|
|
||||||
},
|
|
||||||
validateBinary(path: string): Promise<BinaryValidationResult> {
|
validateBinary(path: string): Promise<BinaryValidationResult> {
|
||||||
return request<BinaryValidationResult>("/api/config/binaries/validate", {
|
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MessageRemovedEvent,
|
MessageRemovedEvent,
|
||||||
MessagePartUpdatedEvent,
|
MessagePartUpdatedEvent,
|
||||||
MessagePartRemovedEvent,
|
MessagePartRemovedEvent,
|
||||||
|
MessagePartDeltaEvent,
|
||||||
} from "../types/message"
|
} from "../types/message"
|
||||||
import type {
|
import type {
|
||||||
EventLspUpdated,
|
EventLspUpdated,
|
||||||
@@ -58,6 +59,7 @@ type SSEEvent =
|
|||||||
| MessageRemovedEvent
|
| MessageRemovedEvent
|
||||||
| MessagePartUpdatedEvent
|
| MessagePartUpdatedEvent
|
||||||
| MessagePartRemovedEvent
|
| MessagePartRemovedEvent
|
||||||
|
| MessagePartDeltaEvent
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventSessionDiff
|
| EventSessionDiff
|
||||||
@@ -118,6 +120,9 @@ class SSEManager {
|
|||||||
case "message.part.updated":
|
case "message.part.updated":
|
||||||
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
|
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
|
||||||
break
|
break
|
||||||
|
case "message.part.delta":
|
||||||
|
this.onMessagePartDelta?.(instanceId, event as MessagePartDeltaEvent)
|
||||||
|
break
|
||||||
case "message.removed":
|
case "message.removed":
|
||||||
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
|
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
|
||||||
break
|
break
|
||||||
@@ -184,6 +189,7 @@ class SSEManager {
|
|||||||
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
||||||
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
|
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
|
||||||
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
|
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
|
||||||
|
onMessagePartDelta?: (instanceId: string, event: MessagePartDeltaEvent) => void
|
||||||
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
|
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
|
||||||
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
|
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
|
||||||
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
|
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
|
import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "./api-client"
|
import { serverApi } from "./api-client"
|
||||||
import { serverEvents } from "./server-events"
|
import { serverEvents } from "./server-events"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
export type ConfigData = AppConfig
|
export type OwnerBucket = Record<string, any>
|
||||||
|
|
||||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
@@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ServerStorage {
|
export class ServerStorage {
|
||||||
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
|
private configOwnerCache = new Map<string, OwnerBucket>()
|
||||||
private configCache: ConfigData | null = null
|
private stateOwnerCache = new Map<string, OwnerBucket>()
|
||||||
private loadPromise: Promise<ConfigData> | null = null
|
private configOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
|
||||||
|
private stateOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
|
||||||
|
private configOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
|
||||||
|
private stateOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
|
||||||
private instanceDataCache = new Map<string, InstanceData>()
|
private instanceDataCache = new Map<string, InstanceData>()
|
||||||
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
||||||
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
serverEvents.on("config.appChanged", (event) => {
|
serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => {
|
||||||
if (event.type !== "config.appChanged") return
|
if (event.type !== "storage.configChanged") return
|
||||||
this.setConfigCache(event.config)
|
this.setOwnerCache("config", event.owner, event.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => {
|
||||||
|
if (event.type !== "storage.stateChanged") return
|
||||||
|
this.setOwnerCache("state", event.owner, event.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
serverEvents.on("instance.dataChanged", (event) => {
|
serverEvents.on("instance.dataChanged", (event) => {
|
||||||
@@ -49,30 +57,56 @@ export class ServerStorage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadConfig(): Promise<ConfigData> {
|
async loadConfigOwner(owner: string): Promise<OwnerBucket> {
|
||||||
if (this.configCache) {
|
const cached = this.configOwnerCache.get(owner)
|
||||||
return this.configCache
|
if (cached) return cached
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.loadPromise) {
|
if (!this.configOwnerLoadPromises.has(owner)) {
|
||||||
this.loadPromise = serverApi
|
const promise = serverApi
|
||||||
.fetchConfig()
|
.fetchConfigOwner<OwnerBucket>(owner)
|
||||||
.then((config) => {
|
.then((value) => {
|
||||||
this.setConfigCache(config)
|
this.setOwnerCache("config", owner, value)
|
||||||
return config
|
return value
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.loadPromise = null
|
this.configOwnerLoadPromises.delete(owner)
|
||||||
})
|
})
|
||||||
|
this.configOwnerLoadPromises.set(owner, promise)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loadPromise
|
return this.configOwnerLoadPromises.get(owner)!
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConfig(next: ConfigData): Promise<ConfigData> {
|
async patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||||
const nextConfig = await serverApi.updateConfig(next)
|
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
|
||||||
this.setConfigCache(nextConfig)
|
this.setOwnerCache("config", owner, updated)
|
||||||
return nextConfig
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStateOwner(owner: string): Promise<OwnerBucket> {
|
||||||
|
const cached = this.stateOwnerCache.get(owner)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
if (!this.stateOwnerLoadPromises.has(owner)) {
|
||||||
|
const promise = serverApi
|
||||||
|
.fetchStateOwner<OwnerBucket>(owner)
|
||||||
|
.then((value) => {
|
||||||
|
this.setOwnerCache("state", owner, value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.stateOwnerLoadPromises.delete(owner)
|
||||||
|
})
|
||||||
|
this.stateOwnerLoadPromises.set(owner, promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.stateOwnerLoadPromises.get(owner)!
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||||
|
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
|
||||||
|
this.setOwnerCache("state", owner, updated)
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||||
@@ -110,12 +144,40 @@ export class ServerStorage {
|
|||||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||||
}
|
}
|
||||||
|
|
||||||
onConfigChanged(listener: (config: ConfigData) => void): () => void {
|
onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
|
||||||
this.configChangeListeners.add(listener)
|
if (!this.configOwnerListeners.has(owner)) {
|
||||||
if (this.configCache) {
|
this.configOwnerListeners.set(owner, new Set())
|
||||||
listener(this.configCache)
|
}
|
||||||
|
const bucket = this.configOwnerListeners.get(owner)!
|
||||||
|
bucket.add(listener)
|
||||||
|
const cached = this.configOwnerCache.get(owner)
|
||||||
|
if (cached) {
|
||||||
|
listener(cached)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
bucket.delete(listener)
|
||||||
|
if (bucket.size === 0) {
|
||||||
|
this.configOwnerListeners.delete(owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
|
||||||
|
if (!this.stateOwnerListeners.has(owner)) {
|
||||||
|
this.stateOwnerListeners.set(owner, new Set())
|
||||||
|
}
|
||||||
|
const bucket = this.stateOwnerListeners.get(owner)!
|
||||||
|
bucket.add(listener)
|
||||||
|
const cached = this.stateOwnerCache.get(owner)
|
||||||
|
if (cached) {
|
||||||
|
listener(cached)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
bucket.delete(listener)
|
||||||
|
if (bucket.size === 0) {
|
||||||
|
this.stateOwnerListeners.delete(owner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return () => this.configChangeListeners.delete(listener)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
|
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
|
||||||
@@ -136,18 +198,30 @@ export class ServerStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setConfigCache(config: ConfigData) {
|
private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) {
|
||||||
if (this.configCache && isDeepEqual(this.configCache, config)) {
|
if (owner === "*") {
|
||||||
this.configCache = config
|
// Full-doc updates are not tracked owner-by-owner; invalidate caches.
|
||||||
|
if (kind === "config") {
|
||||||
|
this.configOwnerCache.clear()
|
||||||
|
} else {
|
||||||
|
this.stateOwnerCache.clear()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.configCache = config
|
|
||||||
this.notifyConfigChanged(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyConfigChanged(config: ConfigData) {
|
const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache
|
||||||
for (const listener of this.configChangeListeners) {
|
const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners
|
||||||
listener(config)
|
|
||||||
|
const previous = cache.get(owner)
|
||||||
|
if (previous && isDeepEqual(previous, value)) {
|
||||||
|
cache.set(owner, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cache.set(owner, value)
|
||||||
|
const bucket = listeners.get(owner)
|
||||||
|
if (!bucket) return
|
||||||
|
for (const listener of bucket) {
|
||||||
|
listener(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ async function bootstrap() {
|
|||||||
document.documentElement.removeAttribute("data-theme")
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await storage.loadConfig()
|
const uiConfig = await storage.loadConfigOwner("ui")
|
||||||
const theme = config?.theme ?? "system"
|
const theme = (uiConfig as any)?.theme ?? "system"
|
||||||
|
|
||||||
if (theme === "system") {
|
if (theme === "system") {
|
||||||
document.documentElement.removeAttribute("data-theme")
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences } from "./preferences"
|
import { serverSettings } from "./preferences"
|
||||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
import { setHasInstances } from "./ui"
|
import { setHasInstances } from "./ui"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
@@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
|||||||
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
||||||
binaryLabel: descriptor.binaryLabel,
|
binaryLabel: descriptor.binaryLabel,
|
||||||
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
||||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
|
const timeInfo = (info.time ?? {}) as { created?: number; end?: number }
|
||||||
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
||||||
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
|
const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined
|
||||||
|
|
||||||
store.upsertMessage({
|
store.upsertMessage({
|
||||||
id: info.id,
|
id: info.id,
|
||||||
@@ -87,7 +87,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
|
|||||||
role: info.role === "user" ? "user" : "assistant",
|
role: info.role === "user" ? "user" : "assistant",
|
||||||
status: options?.status ?? "complete",
|
status: options?.status ?? "complete",
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: completedAt ?? createdAt,
|
updatedAt: endAt ?? createdAt,
|
||||||
bumpRevision: Boolean(options?.bumpRevision),
|
bumpRevision: Boolean(options?.bumpRevision),
|
||||||
})
|
})
|
||||||
store.setMessageInfo(info.id, info)
|
store.setMessageInfo(info.id, info)
|
||||||
@@ -104,6 +104,22 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null |
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyPartDeltaV2(
|
||||||
|
instanceId: string,
|
||||||
|
input: { messageId: string; partId: string; field: string; delta: string },
|
||||||
|
): void {
|
||||||
|
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.applyPartDelta({
|
||||||
|
messageId: input.messageId,
|
||||||
|
partId: input.partId,
|
||||||
|
field: input.field,
|
||||||
|
delta: input.delta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||||
if (!oldId || !newId || oldId === newId) return
|
if (!oldId || !newId || oldId === newId) return
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export interface InstanceMessageStore {
|
|||||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
||||||
upsertMessage: (input: MessageUpsertInput) => void
|
upsertMessage: (input: MessageUpsertInput) => void
|
||||||
applyPartUpdate: (input: PartUpdateInput) => void
|
applyPartUpdate: (input: PartUpdateInput) => void
|
||||||
|
applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void
|
||||||
removeMessage: (messageId: string) => void
|
removeMessage: (messageId: string) => void
|
||||||
removeMessagePart: (messageId: string, partId: string) => void
|
removeMessagePart: (messageId: string, partId: string) => void
|
||||||
bufferPendingPart: (entry: PendingPartEntry) => void
|
bufferPendingPart: (entry: PendingPartEntry) => void
|
||||||
@@ -597,6 +598,45 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
bumpSessionRevision(message.sessionId)
|
bumpSessionRevision(message.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPartDelta(input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) {
|
||||||
|
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = state.messages[input.messageId]
|
||||||
|
if (!message) {
|
||||||
|
// Best-effort: drop deltas for unknown messages.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let applied = false
|
||||||
|
|
||||||
|
setState(
|
||||||
|
"messages",
|
||||||
|
input.messageId,
|
||||||
|
produce((draft: MessageRecord) => {
|
||||||
|
const entry = draft.parts[input.partId]
|
||||||
|
if (!entry?.data) return
|
||||||
|
const part = entry.data as any
|
||||||
|
const currentValue = part?.[input.field]
|
||||||
|
if (typeof currentValue === "string" || currentValue === undefined || currentValue === null) {
|
||||||
|
part[input.field] = `${currentValue ?? ""}${input.delta}`
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
if (!applied) return
|
||||||
|
entry.revision += 1
|
||||||
|
draft.updatedAt = Date.now()
|
||||||
|
if (input.bumpRevision ?? true) {
|
||||||
|
draft.revision += 1
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (applied) {
|
||||||
|
bumpSessionRevision(message.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeMessage(messageId: string) {
|
function removeMessage(messageId: string) {
|
||||||
if (!messageId) return
|
if (!messageId) return
|
||||||
|
|
||||||
@@ -1087,19 +1127,20 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
setState(reconcile(createInitialState(instanceId)))
|
setState(reconcile(createInitialState(instanceId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
instanceId,
|
instanceId,
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
addOrUpdateSession,
|
addOrUpdateSession,
|
||||||
hydrateMessages,
|
hydrateMessages,
|
||||||
upsertMessage,
|
upsertMessage,
|
||||||
applyPartUpdate,
|
applyPartUpdate,
|
||||||
|
applyPartDelta,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
removeMessagePart,
|
removeMessagePart,
|
||||||
bufferPendingPart,
|
bufferPendingPart,
|
||||||
flushPendingParts,
|
flushPendingParts,
|
||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
setMessageInfo,
|
setMessageInfo,
|
||||||
getMessageInfo,
|
getMessageInfo,
|
||||||
@@ -1125,4 +1166,3 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
MessageInfo,
|
MessageInfo,
|
||||||
MessagePartRemovedEvent,
|
MessagePartRemovedEvent,
|
||||||
|
MessagePartDeltaEvent,
|
||||||
MessagePartUpdatedEvent,
|
MessagePartUpdatedEvent,
|
||||||
MessageRemovedEvent,
|
MessageRemovedEvent,
|
||||||
MessageUpdateEvent,
|
MessageUpdateEvent,
|
||||||
@@ -48,6 +49,7 @@ import { loadMessages } from "./session-api"
|
|||||||
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import {
|
import {
|
||||||
applyPartUpdateV2,
|
applyPartUpdateV2,
|
||||||
|
applyPartDeltaV2,
|
||||||
replaceMessageIdV2,
|
replaceMessageIdV2,
|
||||||
reconcilePendingQuestionsV2,
|
reconcilePendingQuestionsV2,
|
||||||
upsertMessageInfoV2,
|
upsertMessageInfoV2,
|
||||||
@@ -298,10 +300,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number }
|
||||||
const nextUpdated =
|
const nextUpdated =
|
||||||
typeof timeInfo.completed === "number" && timeInfo.completed > 0
|
typeof timeInfo.end === "number" && timeInfo.end > 0
|
||||||
? timeInfo.completed
|
? timeInfo.end
|
||||||
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
|
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
|
||||||
? timeInfo.updated
|
? timeInfo.updated
|
||||||
: typeof timeInfo.created === "number" && timeInfo.created > 0
|
: typeof timeInfo.created === "number" && timeInfo.created > 0
|
||||||
@@ -331,14 +333,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const createdAt = info.time?.created ?? Date.now()
|
const createdAt = info.time?.created ?? Date.now()
|
||||||
const completedAt = (info.time as { completed?: number } | undefined)?.completed
|
const endAt = (info.time as { end?: number } | undefined)?.end
|
||||||
store.upsertMessage({
|
store.upsertMessage({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sessionId,
|
sessionId,
|
||||||
role,
|
role,
|
||||||
status,
|
status,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: completedAt ?? createdAt,
|
updatedAt: endAt ?? createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +350,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void {
|
||||||
|
const props = event.properties
|
||||||
|
if (!props) return
|
||||||
|
const { messageID, partID, field, delta } = props
|
||||||
|
if (!messageID || !partID || !field || typeof delta !== "string") return
|
||||||
|
applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta })
|
||||||
|
}
|
||||||
|
|
||||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
|
|
||||||
@@ -625,6 +635,7 @@ function handleQuestionAnswered(
|
|||||||
export {
|
export {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
|
handleMessagePartDelta,
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { agents, providers } from "./session-state"
|
import { agents, providers } from "./session-state"
|
||||||
import { preferences, getAgentModelPreference } from "./preferences"
|
import { uiState, getAgentModelPreference } from "./preferences"
|
||||||
|
|
||||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ function isModelValid(
|
|||||||
function getRecentModelPreferenceForInstance(
|
function getRecentModelPreferenceForInstance(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
): { providerId: string; modelId: string } | undefined {
|
): { providerId: string; modelId: string } | undefined {
|
||||||
const recents = preferences().modelRecents ?? []
|
const recents = uiState().models.recents ?? []
|
||||||
for (const item of recents) {
|
for (const item of recents) {
|
||||||
if (isModelValid(instanceId, item)) {
|
if (isModelValid(instanceId, item)) {
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
|
handleMessagePartDelta,
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
@@ -74,6 +75,7 @@ import {
|
|||||||
|
|
||||||
sseManager.onMessageUpdate = handleMessageUpdate
|
sseManager.onMessageUpdate = handleMessageUpdate
|
||||||
sseManager.onMessagePartUpdated = handleMessageUpdate
|
sseManager.onMessagePartUpdated = handleMessageUpdate
|
||||||
|
sseManager.onMessagePartDelta = handleMessagePartDelta
|
||||||
sseManager.onMessageRemoved = handleMessageRemoved
|
sseManager.onMessageRemoved = handleMessageRemoved
|
||||||
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
||||||
sseManager.onSessionUpdate = handleSessionUpdate
|
sseManager.onSessionUpdate = handleSessionUpdate
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ export type {
|
|||||||
SDKMessage
|
SDKMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server streaming event: append-only delta updates.
|
||||||
|
// Emitted over SSE by newer OpenCode builds.
|
||||||
|
export interface MessagePartDeltaEvent {
|
||||||
|
type: "message.part.delta"
|
||||||
|
properties: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
partID: string
|
||||||
|
field: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderCache {
|
export interface RenderCache {
|
||||||
text: string
|
text: string
|
||||||
html: string
|
html: string
|
||||||
|
|||||||
Reference in New Issue
Block a user