From aad4ef2820580c2efc4928d38356446e48bcb189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 15 May 2026 11:07:56 +0200 Subject: [PATCH] feat(ui): add OpenCode session repair tool Expose a command-palette-driven repair flow that analyzes the OpenCode database, reports likely broken or hidden sessions separately from non-blocking metadata gaps, and lets users apply scoped repairs with an automatic backup first. This gives affected users a built-in recovery path instead of requiring manual SQLite edits or ad hoc scripts. The server now inspects the global OpenCode database, derives safe metadata repairs from existing assistant messages, and offers a separate directory-reconciliation pass based on known CodeNomad workspaces. The UI adds a modal report plus targeted actions so users can normalize incomplete metadata without assuming that every incomplete session is currently broken. Validation: npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/tauri-app --- packages/server/src/api-types.ts | 56 +++ packages/server/src/index.ts | 9 + .../server/src/opencode/session-repair.ts | 445 ++++++++++++++++++ packages/server/src/server/http-server.ts | 4 + .../server/routes/opencode-session-repair.ts | 36 ++ packages/ui/src/App.tsx | 2 + .../opencode-session-repair-dialog.tsx | 155 ++++++ packages/ui/src/lib/api-client.ts | 12 + packages/ui/src/lib/hooks/use-commands.ts | 12 + .../ui/src/lib/i18n/messages/en/commands.ts | 40 ++ .../ui/src/lib/i18n/messages/fr/commands.ts | 40 ++ .../ui/src/stores/opencode-session-repair.ts | 97 ++++ 12 files changed, 908 insertions(+) create mode 100644 packages/server/src/opencode/session-repair.ts create mode 100644 packages/server/src/server/routes/opencode-session-repair.ts create mode 100644 packages/ui/src/components/opencode-session-repair-dialog.tsx create mode 100644 packages/ui/src/stores/opencode-session-repair.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d591dba44..0efc56f5d 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -59,6 +59,62 @@ export interface WorkspaceDeleteResponse { status: WorkspaceStatus } +export interface OpenCodeSessionRepairIssueSession { + id: string + title: string + projectId: string + directory: string + version: string + likelyBroken: boolean + likelyHidden: boolean + metadataIncompleteOnly: boolean + repairableSafeMetadata: boolean + missingAssistantAgentMessages: number + missingSessionAgent: boolean + missingSessionModel: boolean + missingSessionPath: boolean + recommendedDirectory?: string +} + +export interface OpenCodeSessionRepairAnalysis { + analyzedAt: string + dbPath: string + sessionCount: number + assistantMessageCount: number + issues: { + sessionsLikelyBroken: number + sessionsLikelyHidden: number + sessionsWithIncompleteMetadataOnly: number + sessionsWithRepairableSafeMetadata: number + sessionsWithRemainingIncompleteMetadata: number + sessionsWithMissingAssistantAgentMessages: number + sessionsMissingSessionAgent: number + sessionsMissingSessionModel: number + sessionsMissingSessionPath: number + sessionsWithRecommendedDirectoryRepair: number + } + affectedSessions: OpenCodeSessionRepairIssueSession[] +} + +export type OpenCodeSessionRepairMode = "important" | "normalize" + +export interface OpenCodeSessionRepairRequest { + mode: OpenCodeSessionRepairMode +} + +export interface OpenCodeSessionRepairResult { + executedAt: string + backupPath: string + mode: OpenCodeSessionRepairMode + repaired: { + assistantMessages: number + sessionAgents: number + sessionModels: number + sessionPaths: number + sessionDirectories: number + } + analysis: OpenCodeSessionRepairAnalysis +} export type WorktreeKind = "root" | "worktree" export interface WorktreeDescriptor { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f4dc70fe4..c6a58b803 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,6 +31,7 @@ import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" import { runCliUpgrade } from "./cli-upgrade" +import { OpenCodeSessionRepairService } from "./opencode/session-repair" const require = createRequire(import.meta.url) @@ -341,6 +342,12 @@ async function main() { eventBus, logger: logger.child({ component: "sidecars" }), }) + const openCodeSessionRepairService = new OpenCodeSessionRepairService({ + settings, + binaryResolver, + workspaceManager, + logger: logger.child({ component: "opencode-session-repair" }), + }) const previewManager = new PreviewManager() const instanceEventBridge = new InstanceEventBridge({ workspaceManager, @@ -443,6 +450,7 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + openCodeSessionRepairService, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -470,6 +478,7 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + openCodeSessionRepairService, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, diff --git a/packages/server/src/opencode/session-repair.ts b/packages/server/src/opencode/session-repair.ts new file mode 100644 index 000000000..04c088dff --- /dev/null +++ b/packages/server/src/opencode/session-repair.ts @@ -0,0 +1,445 @@ +import { backup, DatabaseSync } from "node:sqlite" +import { spawnSync } from "child_process" +import { promises as fsp } from "fs" +import os from "os" +import path from "path" + +import type { + OpenCodeSessionRepairAnalysis, + OpenCodeSessionRepairIssueSession, + OpenCodeSessionRepairMode, + OpenCodeSessionRepairResult, +} from "../api-types" +import type { Logger } from "../logger" +import type { BinaryResolver } from "../settings/binaries" +import type { SettingsService } from "../settings/service" +import { buildSpawnSpec } from "../workspaces/spawn" +import type { WorkspaceManager } from "../workspaces/manager" + +type SessionRow = { + id: string + projectId: string + title: string + directory: string | null + version: string + agent: string | null + model: string | null + path: string | null +} + +type DerivedSessionMetadata = { + agent?: string + model?: string +} + +type SessionRepairState = { + dbPath: string + assistantMessageCount: number + sessions: SessionRow[] + missingAssistantAgentMessages: Map + derivedMetadataBySession: Map + recommendedDirectoryBySession: Map +} + +const BACKUP_DIR_PREFIX = "codenomad-opencode-session-repair-" + +function normalizeDirectoryKey(input: string | null | undefined): string { + const normalized = path.normalize((input ?? "").trim()) + if (!normalized) return "" + return process.platform === "win32" ? normalized.toLowerCase() : normalized +} + +function parseModelValue(value: string | null): Record | null { + if (!value) return null + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +export class OpenCodeSessionRepairService { + constructor( + private readonly deps: { + settings: SettingsService + binaryResolver: BinaryResolver + workspaceManager: WorkspaceManager + logger: Logger + }, + ) {} + + async analyze(): Promise { + const state = this.loadRepairState() + return this.buildAnalysis(state) + } + + async repair(mode: OpenCodeSessionRepairMode): Promise { + const before = this.loadRepairState() + const backupPath = await this.createBackup(before.dbPath) + const db = new DatabaseSync(before.dbPath) + + let repairedAssistantMessages = 0 + let repairedSessionAgents = 0 + let repairedSessionModels = 0 + let repairedSessionPaths = 0 + let repairedSessionDirectories = 0 + + try { + db.exec("BEGIN TRANSACTION") + + if (mode === "important") { + const rows = db.prepare(` + SELECT id, data + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.agent') IS NULL + AND json_extract(data, '$.mode') IS NOT NULL + `).all() as Array<{ id: string; data: string }> + + const updateMessage = db.prepare("UPDATE message SET data = ? WHERE id = ?") + for (const row of rows) { + const data = JSON.parse(row.data) as Record + const modeValue = typeof data.mode === "string" ? data.mode.trim() : "" + if (!modeValue || typeof data.agent === "string") continue + data.agent = modeValue + updateMessage.run(JSON.stringify(data), row.id) + repairedAssistantMessages += 1 + } + + } + + if (mode === "normalize") { + const updateSession = db.prepare("UPDATE session SET agent = ?, model = ?, path = ? WHERE id = ?") + for (const session of before.sessions) { + const missingMessageAgents = before.missingAssistantAgentMessages.get(session.id) ?? 0 + const recommendedDirectory = before.recommendedDirectoryBySession.get(session.id) + if (missingMessageAgents > 0 || recommendedDirectory) { + continue + } + + const derived = before.derivedMetadataBySession.get(session.id) ?? {} + const nextAgent = session.agent ?? derived.agent ?? null + const nextModel = session.model ?? derived.model ?? null + const nextPath = session.path ?? "" + + if (nextAgent === session.agent && nextModel === session.model && nextPath === session.path) continue + updateSession.run(nextAgent, nextModel, nextPath, session.id) + if (session.agent === null && nextAgent !== null) repairedSessionAgents += 1 + if (session.model === null && nextModel !== null) repairedSessionModels += 1 + if (session.path === null) repairedSessionPaths += 1 + } + } + + if (mode === "important") { + const updateDirectory = db.prepare("UPDATE session SET directory = ? WHERE id = ?") + for (const [sessionId, targetDirectory] of before.recommendedDirectoryBySession) { + const session = before.sessions.find((entry) => entry.id === sessionId) + if (!session) continue + if (session.directory === targetDirectory) continue + updateDirectory.run(targetDirectory, sessionId) + repairedSessionDirectories += 1 + } + } + + db.exec("COMMIT") + } catch (error) { + db.exec("ROLLBACK") + throw error + } finally { + db.close() + } + + const after = await this.analyze() + return { + backupPath, + executedAt: new Date().toISOString(), + mode, + repaired: { + assistantMessages: repairedAssistantMessages, + sessionAgents: repairedSessionAgents, + sessionModels: repairedSessionModels, + sessionPaths: repairedSessionPaths, + sessionDirectories: repairedSessionDirectories, + }, + analysis: after, + } + } + + private loadRepairState(): SessionRepairState { + const dbPath = this.resolveDbPath() + const db = new DatabaseSync(dbPath, { readOnly: true }) + + try { + const sessionRows = db.prepare(` + SELECT id, project_id AS projectId, title, directory, version, agent, model, path + FROM session + ORDER BY time_created + `).all() as SessionRow[] + + const assistantMessageCountRow = db.prepare(` + SELECT COUNT(*) AS count + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + `).get() as { count: number } + + const missingAssistantAgentRows = db.prepare(` + SELECT session_id AS sessionId, COUNT(*) AS count + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.agent') IS NULL + AND json_extract(data, '$.mode') IS NOT NULL + GROUP BY session_id + `).all() as Array<{ sessionId: string; count: number }> + + const derivedMetadataRows = db.prepare(` + SELECT session_id AS sessionId, + json_extract(data, '$.mode') AS mode, + json_extract(data, '$.providerID') AS providerID, + json_extract(data, '$.modelID') AS modelID, + json_extract(data, '$.variant') AS variant + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.mode') IS NOT NULL + ORDER BY session_id, time_created + `).all() as Array<{ sessionId: string; mode: string | null; providerID: string | null; modelID: string | null; variant: string | null }> + + const missingAssistantAgentMessages = new Map() + for (const row of missingAssistantAgentRows) { + missingAssistantAgentMessages.set(row.sessionId, row.count) + } + + const derivedMetadataBySession = new Map() + for (const row of derivedMetadataRows) { + if (derivedMetadataBySession.has(row.sessionId)) continue + const metadata: DerivedSessionMetadata = {} + if (typeof row.mode === "string" && row.mode.trim()) { + metadata.agent = row.mode.trim() + } + if (typeof row.providerID === "string" && row.providerID.trim() && typeof row.modelID === "string" && row.modelID.trim()) { + const model: Record = { + id: row.modelID.trim(), + providerID: row.providerID.trim(), + } + if (typeof row.variant === "string" && row.variant.trim()) { + model.variant = row.variant.trim() + } + metadata.model = JSON.stringify(model) + } + derivedMetadataBySession.set(row.sessionId, metadata) + } + + const recommendedDirectoryBySession = this.buildRecommendedDirectoryRepairs(sessionRows, missingAssistantAgentMessages) + + return { + dbPath, + assistantMessageCount: assistantMessageCountRow.count, + sessions: sessionRows, + missingAssistantAgentMessages, + derivedMetadataBySession, + recommendedDirectoryBySession, + } + } finally { + db.close() + } + } + + private buildRecommendedDirectoryRepairs( + sessions: SessionRow[], + missingAssistantAgentMessages: Map, + ): Map { + const knownDirectories = this.getKnownDirectories() + const byProject = new Map() + for (const session of sessions) { + const list = byProject.get(session.projectId) ?? [] + list.push(session) + byProject.set(session.projectId, list) + } + + const recommendations = new Map() + + for (const projectSessions of byProject.values()) { + const directories = new Map() + for (const session of projectSessions) { + const key = normalizeDirectoryKey(session.directory) + if (!key) continue + const existing = directories.get(key) + if (existing) { + existing.sessions.push(session) + continue + } + directories.set(key, { + display: session.directory ?? "", + sessions: [session], + known: knownDirectories.has(key), + }) + } + + if (directories.size <= 1) continue + + const known = Array.from(directories.values()).filter((entry) => entry.known) + let target: { display: string; sessions: SessionRow[]; known: boolean } | null = null + if (known.length === 1) { + target = known[0] + } else if (known.length === 0) { + const ordered = Array.from(directories.values()).sort((left, right) => right.sessions.length - left.sessions.length) + if (ordered.length > 1 && ordered[0].sessions.length > ordered[1].sessions.length && ordered[0].sessions.length > 1) { + target = ordered[0] + } + } + + if (!target) continue + + for (const entry of directories.values()) { + if (entry.display === target.display) continue + for (const session of entry.sessions) { + const missingTopLevel = session.agent === null || session.model === null || session.path === null + const missingMessages = (missingAssistantAgentMessages.get(session.id) ?? 0) > 0 + if (missingTopLevel || missingMessages) { + recommendations.set(session.id, target.display) + } + } + } + } + + return recommendations + } + + private buildAnalysis(state: SessionRepairState): OpenCodeSessionRepairAnalysis { + const affectedSessions: OpenCodeSessionRepairIssueSession[] = [] + let sessionsLikelyBroken = 0 + let sessionsLikelyHidden = 0 + let sessionsWithIncompleteMetadataOnly = 0 + let sessionsWithRepairableSafeMetadata = 0 + let sessionsWithRemainingIncompleteMetadata = 0 + let sessionsWithMissingAssistantAgentMessages = 0 + let sessionsMissingSessionAgent = 0 + let sessionsMissingSessionModel = 0 + let sessionsMissingSessionPath = 0 + + for (const session of state.sessions) { + const missingMessageAgents = state.missingAssistantAgentMessages.get(session.id) ?? 0 + const missingSessionAgent = session.agent === null + const missingSessionModel = session.model === null + const missingSessionPath = session.path === null + const recommendedDirectory = state.recommendedDirectoryBySession.get(session.id) + const likelyBroken = missingMessageAgents > 0 + const likelyHidden = Boolean(recommendedDirectory) + const metadataIncompleteOnly = !likelyBroken && !likelyHidden && (missingSessionAgent || missingSessionModel || missingSessionPath) + const derived = state.derivedMetadataBySession.get(session.id) ?? {} + const repairableSafeMetadata = + metadataIncompleteOnly && + ((missingSessionAgent && Boolean(derived.agent)) || + (missingSessionModel && Boolean(derived.model))) + + if (missingMessageAgents > 0) sessionsWithMissingAssistantAgentMessages += 1 + if (missingSessionAgent) sessionsMissingSessionAgent += 1 + if (missingSessionModel) sessionsMissingSessionModel += 1 + if (missingSessionPath) sessionsMissingSessionPath += 1 + if (likelyBroken) sessionsLikelyBroken += 1 + if (likelyHidden) sessionsLikelyHidden += 1 + if (metadataIncompleteOnly) sessionsWithIncompleteMetadataOnly += 1 + if (repairableSafeMetadata) sessionsWithRepairableSafeMetadata += 1 + if (metadataIncompleteOnly && !repairableSafeMetadata) sessionsWithRemainingIncompleteMetadata += 1 + + if (missingMessageAgents === 0 && !missingSessionAgent && !missingSessionModel && !missingSessionPath && !recommendedDirectory) { + continue + } + + affectedSessions.push({ + id: session.id, + title: session.title, + projectId: session.projectId, + directory: session.directory ?? "", + version: session.version, + likelyBroken, + likelyHidden, + metadataIncompleteOnly, + repairableSafeMetadata, + missingAssistantAgentMessages: missingMessageAgents, + missingSessionAgent, + missingSessionModel, + missingSessionPath, + recommendedDirectory, + }) + } + + return { + analyzedAt: new Date().toISOString(), + dbPath: state.dbPath, + sessionCount: state.sessions.length, + assistantMessageCount: state.assistantMessageCount, + issues: { + sessionsLikelyBroken, + sessionsLikelyHidden, + sessionsWithIncompleteMetadataOnly, + sessionsWithRepairableSafeMetadata, + sessionsWithRemainingIncompleteMetadata, + sessionsWithMissingAssistantAgentMessages, + sessionsMissingSessionAgent, + sessionsMissingSessionModel, + sessionsMissingSessionPath, + sessionsWithRecommendedDirectoryRepair: state.recommendedDirectoryBySession.size, + }, + affectedSessions, + } + } + + private getKnownDirectories(): Set { + const directories = new Set() + for (const workspace of this.deps.workspaceManager.list()) { + const key = normalizeDirectoryKey(workspace.path) + if (key) directories.add(key) + } + + const uiState = this.deps.settings.getOwner("state", "ui") as { recentFolders?: Array<{ path?: string }> } + const recentFolders = Array.isArray(uiState?.recentFolders) ? uiState.recentFolders : [] + for (const folder of recentFolders) { + const key = normalizeDirectoryKey(folder?.path) + if (key) directories.add(key) + } + + return directories + } + + private resolveDbPath(): string { + const binary = this.deps.binaryResolver.resolveDefault().path + const spec = buildSpawnSpec(binary, ["db", "path"], { env: process.env }) + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + cwd: spec.cwd, + env: spec.env, + windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments), + }) + + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Failed to resolve OpenCode database path").trim()) + } + + const resolved = String(result.stdout ?? "").trim() + if (!resolved) { + throw new Error("OpenCode database path is empty") + } + + return resolved + } + + private async createBackup(dbPath: string): Promise { + const backupDir = await fsp.mkdtemp(path.join(os.tmpdir(), BACKUP_DIR_PREFIX)) + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupPath = path.join(backupDir, `opencode-session-repair-${timestamp}.db`) + const sourceDb = new DatabaseSync(dbPath, { readOnly: true }) + + try { + await backup(sourceDb, backupPath) + } finally { + sourceDb.close() + } + + this.deps.logger.info({ backupPath, dbPath }, "Created OpenCode session repair backup") + return backupPath + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index faa53d3fd..e52369824 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -32,6 +32,7 @@ import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" +import type { OpenCodeSessionRepairService } from "../opencode/session-repair" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" @@ -42,6 +43,7 @@ import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" +import { registerOpenCodeSessionRepairRoutes } from "./routes/opencode-session-repair" interface HttpServerDeps { bindHost: string @@ -64,6 +66,7 @@ interface HttpServerDeps { pluginChannel: PluginChannelManager voiceModeManager: VoiceModeManager remoteProxySessionManager: RemoteProxySessionManager + openCodeSessionRepairService: OpenCodeSessionRepairService uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -310,6 +313,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) + registerOpenCodeSessionRepairRoutes(app, { repairService: deps.openCodeSessionRepairService }) if (deps.uiDevServerUrl) { diff --git a/packages/server/src/server/routes/opencode-session-repair.ts b/packages/server/src/server/routes/opencode-session-repair.ts new file mode 100644 index 000000000..7f59f8694 --- /dev/null +++ b/packages/server/src/server/routes/opencode-session-repair.ts @@ -0,0 +1,36 @@ +import type { FastifyInstance, FastifyReply } from "fastify" +import { z } from "zod" + +import type { OpenCodeSessionRepairService } from "../../opencode/session-repair" + +interface RouteDeps { + repairService: OpenCodeSessionRepairService +} + +const RepairRequestSchema = z.object({ + mode: z.enum(["important", "normalize"]), +}) + +export function registerOpenCodeSessionRepairRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/opencode/session-repair/analyze", async (_request, reply) => { + try { + return await deps.repairService.analyze() + } catch (error) { + return handleError(error, reply) + } + }) + + app.post("/api/opencode/session-repair/execute", async (request, reply) => { + try { + const body = RepairRequestSchema.parse(request.body ?? {}) + return await deps.repairService.repair(body.mode) + } catch (error) { + return handleError(error, reply) + } + }) +} + +function handleError(error: unknown, reply: FastifyReply) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Unable to repair OpenCode sessions" } +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2aa430fcd..05049cd42 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -9,6 +9,7 @@ import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell2" +import OpencodeSessionRepairDialog from "./components/opencode-session-repair-dialog" import { SettingsScreen } from "./components/settings-screen" import { SideCarPickerDialog } from "./components/sidecar-picker-dialog" import { SideCarView } from "./components/sidecar-view" @@ -654,6 +655,7 @@ const App: Component = () => { + setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> !open && dismissAlreadyOpenFolderChoice()}> diff --git a/packages/ui/src/components/opencode-session-repair-dialog.tsx b/packages/ui/src/components/opencode-session-repair-dialog.tsx new file mode 100644 index 000000000..761190c7e --- /dev/null +++ b/packages/ui/src/components/opencode-session-repair-dialog.tsx @@ -0,0 +1,155 @@ +import { Dialog } from "@kobalte/core/dialog" +import { For, Show, createMemo, type Component } from "solid-js" + +import { useI18n } from "../lib/i18n" +import { + applyOpenCodeSessionRepair, + closeOpenCodeSessionRepairDialog, + openCodeSessionRepairDialogState, + openOpenCodeSessionRepairDialogState, +} from "../stores/opencode-session-repair" + +const OpencodeSessionRepairDialog: Component = () => { + const { t } = useI18n() + + const analysis = createMemo(() => openCodeSessionRepairDialogState().analysis) + const result = createMemo(() => openCodeSessionRepairDialogState().result) + const importantIssueCount = createMemo(() => { + const issues = analysis()?.issues + if (!issues) return 0 + return issues.sessionsLikelyBroken + issues.sessionsLikelyHidden + }) + const highlightedSessions = createMemo(() => + (analysis()?.affectedSessions ?? []).filter((session) => session.likelyBroken || session.likelyHidden), + ) + const canRepairImportantIssues = createMemo(() => importantIssueCount() > 0) + const canNormalizeRemainingMetadata = createMemo(() => { + const issues = analysis()?.issues + if (!issues) return false + return issues.sessionsWithIncompleteMetadataOnly > 0 && issues.sessionsWithRepairableSafeMetadata > 0 + }) + + return ( + { + if (!next) closeOpenCodeSessionRepairDialog() + }} + > + + + +
+ {t("commands.repairOpenCodeSessions.dialog.title")} + + {t("commands.repairOpenCodeSessions.dialog.description")} + +
+ +
+ +
+ {t("commands.repairOpenCodeSessions.status.analyzing")} +
+
+ + + {(error) => ( +
+ {error()} +
+ )} +
+ + + {(report) => ( + <> +
+ + + + + + +
+ + 0} fallback={
{t("commands.repairOpenCodeSessions.report.noHighlightedIssues", { count: report().issues.sessionsWithIncompleteMetadataOnly })}
}> +
+
+ {t("commands.repairOpenCodeSessions.report.highlightedSessions")} +
+
+ + {(session) => ( +
+
{session.title || session.id}
+
{session.id}
+
{session.directory}
+
+ {t("commands.repairOpenCodeSessions.badge.likelyBroken")} + {t("commands.repairOpenCodeSessions.badge.likelyHidden")} + 0}> + {t("commands.repairOpenCodeSessions.badge.missingMessageAgent", { count: session.missingAssistantAgentMessages })} + + {t("commands.repairOpenCodeSessions.badge.directoryRepair")} +
+
+ )} +
+
+
+
+ + 0}> +
+ {t("commands.repairOpenCodeSessions.report.incompleteOnlyNote", { + count: report().issues.sessionsWithIncompleteMetadataOnly, + nonRepairable: report().issues.sessionsWithRemainingIncompleteMetadata, + })} +
+
+ + )} +
+ + + {(repairResult) => ( +
+
{t("commands.repairOpenCodeSessions.result.success")}
+
{t("commands.repairOpenCodeSessions.result.mode", { mode: repairResult().mode })}
+
{t("commands.repairOpenCodeSessions.result.backup", { path: repairResult().backupPath })}
+
+ )} +
+
+ +
+ + + +
+
+
+
+ ) +} + +const SummaryCard: Component<{ label: string; value: string }> = (props) => ( +
+
{props.label}
+
{props.value}
+
+) + +const IssueBadge: Component<{ children: any }> = (props) => ( + {props.children} +) + +export default OpencodeSessionRepairDialog diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index e9acec27b..899f8bdce 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -8,6 +8,9 @@ import type { FileSystemFileContentResponse, FileSystemListResponse, InstanceData, + OpenCodeSessionRepairAnalysis, + OpenCodeSessionRepairRequest, + OpenCodeSessionRepairResult, SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse, @@ -266,6 +269,15 @@ export const serverApi = { fetchServerMeta(): Promise { return request("/api/meta") }, + analyzeOpenCodeSessionRepair(): Promise { + return request("/api/opencode/session-repair/analyze") + }, + executeOpenCodeSessionRepair(payload: OpenCodeSessionRepairRequest): Promise { + return request("/api/opencode/session-repair/execute", { + method: "POST", + body: JSON.stringify(payload), + }) + }, probeRemoteServer(payload: RemoteServerProbeRequest): Promise { return request("/api/remote-servers/probe", { method: "POST", diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 160cd87ff..9eb24e7fc 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -11,6 +11,7 @@ import type { Instance } from "../../types/instance" import type { MessageRecord } from "../../stores/message-v2/types" import { messageStoreBus } from "../../stores/message-v2/bus" import { cleanupBlankSessions } from "../../stores/session-state" +import { openOpenCodeSessionRepairDialog } from "../../stores/opencode-session-repair" import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" @@ -444,6 +445,17 @@ export function useCommands(options: UseCommandsOptions) { log.info("Show help modal (not implemented)") }, }) + + commandRegistry.register({ + id: "repair-opencode-sessions", + label: () => tGlobal("commands.repairOpenCodeSessions.label"), + description: () => tGlobal("commands.repairOpenCodeSessions.description"), + category: "Session", + keywords: () => splitKeywords("commands.repairOpenCodeSessions.keywords"), + action: async () => { + await openOpenCodeSessionRepairDialog() + }, + }) } function executeCommand(command: Command) { diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index a61bddd2c..4215642d6 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -146,6 +146,46 @@ export const commandMessages = { "commands.showHelp.description": "Display keyboard shortcuts and help", "commands.showHelp.keywords": "shortcuts, help", + "commands.repairOpenCodeSessions.label": "Repair OpenCode Sessions...", + "commands.repairOpenCodeSessions.description": "Analyze the OpenCode database and repair detected session metadata issues", + "commands.repairOpenCodeSessions.keywords": "opencode, session, repair, analyze, metadata, database", + "commands.repairOpenCodeSessions.dialog.title": "Repair OpenCode Sessions", + "commands.repairOpenCodeSessions.dialog.description": "Analyze the full OpenCode database, review the detected issues, and choose which repairs to apply.", + "commands.repairOpenCodeSessions.status.analyzing": "Analyzing the OpenCode database...", + "commands.repairOpenCodeSessions.report.noIssues": "No repairable session issues were detected.", + "commands.repairOpenCodeSessions.report.highlightedSessions": "Sessions Likely Broken Or Hidden", + "commands.repairOpenCodeSessions.report.noHighlightedIssues": "No sessions currently look broken or hidden. {count} sessions still have incomplete metadata.", + "commands.repairOpenCodeSessions.report.incompleteOnlyNote": "{count} sessions still have incomplete metadata. {nonRepairable} of them can't be normalized automatically, but that is not currently causing visible problems.", + "commands.repairOpenCodeSessions.summary.sessions": "Sessions in database", + "commands.repairOpenCodeSessions.summary.likelyBroken": "Sessions likely to fail opening", + "commands.repairOpenCodeSessions.summary.likelyHidden": "Sessions likely hidden by directory mismatch", + "commands.repairOpenCodeSessions.summary.incompleteOnly": "Sessions with remaining non-blocking metadata gaps", + "commands.repairOpenCodeSessions.summary.missingMessageAgent": "Sessions with missing message agent", + "commands.repairOpenCodeSessions.summary.directoryRepairs": "Sessions with recommended directory repair", + "commands.repairOpenCodeSessions.badge.likelyBroken": "Likely broken", + "commands.repairOpenCodeSessions.badge.likelyHidden": "Likely hidden", + "commands.repairOpenCodeSessions.badge.missingMessageAgent": "{count} messages missing agent", + "commands.repairOpenCodeSessions.badge.missingSessionAgent": "Missing session agent", + "commands.repairOpenCodeSessions.badge.missingSessionModel": "Missing session model", + "commands.repairOpenCodeSessions.badge.missingSessionPath": "Missing session path", + "commands.repairOpenCodeSessions.badge.directoryRepair": "Directory repair", + "commands.repairOpenCodeSessions.actions.reanalyze": "Reanalyze", + "commands.repairOpenCodeSessions.actions.normalizeRemaining": "Normalize Remaining Metadata", + "commands.repairOpenCodeSessions.actions.repairImportant": "Repair Important Issues", + "commands.repairOpenCodeSessions.actions.applying": "Applying...", + "commands.repairOpenCodeSessions.actions.close": "Close", + "commands.repairOpenCodeSessions.confirm.title": "Repair OpenCode sessions", + "commands.repairOpenCodeSessions.confirm.message": "Create a backup and apply the selected repair to the OpenCode database?", + "commands.repairOpenCodeSessions.confirm.detail.normalize": "This only fills top-level session metadata that can be safely derived from existing messages. It does not repair hidden sessions or sessions that currently fail to open.", + "commands.repairOpenCodeSessions.confirm.detail.important": "This repairs sessions that currently look hidden or likely to fail opening. It does not perform broad metadata normalization on otherwise working sessions.", + "commands.repairOpenCodeSessions.confirm.confirmLabel": "Create backup and repair", + "commands.repairOpenCodeSessions.confirm.cancelLabel": "Cancel", + "commands.repairOpenCodeSessions.toast.success": "OpenCode session repair completed", + "commands.repairOpenCodeSessions.toast.error": "OpenCode session repair failed", + "commands.repairOpenCodeSessions.result.success": "Repair completed successfully.", + "commands.repairOpenCodeSessions.result.mode": "Applied repair mode: {mode}", + "commands.repairOpenCodeSessions.result.backup": "Backup created at {path}", + "commands.custom.argumentsPrompt.message": "Arguments for /{name}", "commands.custom.argumentsPrompt.title": "Custom command", "commands.custom.argumentsPrompt.inputLabel": "Arguments", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 6543f9e30..dfd30e22f 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -146,6 +146,46 @@ export const commandMessages = { "commands.showHelp.description": "Afficher les raccourcis clavier et l'aide", "commands.showHelp.keywords": "raccourcis, aide", + "commands.repairOpenCodeSessions.label": "Reparer les sessions OpenCode...", + "commands.repairOpenCodeSessions.description": "Analyser la base OpenCode et reparer les problemes de metadonnees detectes", + "commands.repairOpenCodeSessions.keywords": "opencode, session, reparation, analyse, metadonnees, base, base de donnees", + "commands.repairOpenCodeSessions.dialog.title": "Reparer les sessions OpenCode", + "commands.repairOpenCodeSessions.dialog.description": "Analyse toute la base OpenCode, affiche les problemes detectes et propose les reparations possibles.", + "commands.repairOpenCodeSessions.status.analyzing": "Analyse de la base OpenCode...", + "commands.repairOpenCodeSessions.report.noIssues": "Aucun probleme de session reparable n'a ete detecte.", + "commands.repairOpenCodeSessions.report.highlightedSessions": "Sessions probablement cassees ou cachees", + "commands.repairOpenCodeSessions.report.noHighlightedIssues": "Aucune session ne semble actuellement cassee ou cachee. {count} sessions ont encore des metadonnees incompletes.", + "commands.repairOpenCodeSessions.report.incompleteOnlyNote": "{count} sessions ont encore des metadonnees incompletes. {nonRepairable} d'entre elles ne peuvent pas etre normalisees automatiquement, mais cela ne cause pas de probleme visible pour l'instant.", + "commands.repairOpenCodeSessions.summary.sessions": "Sessions dans la base", + "commands.repairOpenCodeSessions.summary.likelyBroken": "Sessions susceptibles d'echouer a l'ouverture", + "commands.repairOpenCodeSessions.summary.likelyHidden": "Sessions probablement cachees par un mauvais dossier", + "commands.repairOpenCodeSessions.summary.incompleteOnly": "Sessions avec metadonnees encore incompletes mais non bloquantes", + "commands.repairOpenCodeSessions.summary.missingMessageAgent": "Sessions avec agent manquant dans des messages", + "commands.repairOpenCodeSessions.summary.directoryRepairs": "Sessions avec reaffectation de dossier recommandee", + "commands.repairOpenCodeSessions.badge.likelyBroken": "Probablement cassee", + "commands.repairOpenCodeSessions.badge.likelyHidden": "Probablement cachee", + "commands.repairOpenCodeSessions.badge.missingMessageAgent": "{count} messages sans agent", + "commands.repairOpenCodeSessions.badge.missingSessionAgent": "Agent de session manquant", + "commands.repairOpenCodeSessions.badge.missingSessionModel": "Modele de session manquant", + "commands.repairOpenCodeSessions.badge.missingSessionPath": "Chemin de session manquant", + "commands.repairOpenCodeSessions.badge.directoryRepair": "Reparation de dossier", + "commands.repairOpenCodeSessions.actions.reanalyze": "Relancer l'analyse", + "commands.repairOpenCodeSessions.actions.normalizeRemaining": "Normaliser les metadonnees restantes", + "commands.repairOpenCodeSessions.actions.repairImportant": "Reparer les problemes importants", + "commands.repairOpenCodeSessions.actions.applying": "Application...", + "commands.repairOpenCodeSessions.actions.close": "Fermer", + "commands.repairOpenCodeSessions.confirm.title": "Reparer les sessions OpenCode", + "commands.repairOpenCodeSessions.confirm.message": "Creer une sauvegarde puis appliquer la reparation selectionnee a la base OpenCode ?", + "commands.repairOpenCodeSessions.confirm.detail.safe": "Met a jour les metadonnees de messages et de sessions reparables sans modifier les attributions de dossier.", + "commands.repairOpenCodeSessions.confirm.detail.directory": "Met a jour uniquement les attributions de dossier recommandees a partir des workspaces connus par CodeNomad.", + "commands.repairOpenCodeSessions.confirm.detail.all": "Applique a la fois les reparations de metadonnees sures et les corrections recommandees de rattachement de dossier.", + "commands.repairOpenCodeSessions.confirm.confirmLabel": "Sauvegarder et reparer", + "commands.repairOpenCodeSessions.confirm.cancelLabel": "Annuler", + "commands.repairOpenCodeSessions.toast.success": "Reparation des sessions OpenCode terminee", + "commands.repairOpenCodeSessions.toast.error": "La reparation des sessions OpenCode a echoue", + "commands.repairOpenCodeSessions.result.success": "La reparation est terminee avec succes.", + "commands.repairOpenCodeSessions.result.backup": "Sauvegarde creee ici : {path}", + "commands.custom.argumentsPrompt.message": "Arguments pour /{name}", "commands.custom.argumentsPrompt.title": "Commande personnalisée", "commands.custom.argumentsPrompt.inputLabel": "Arguments", diff --git a/packages/ui/src/stores/opencode-session-repair.ts b/packages/ui/src/stores/opencode-session-repair.ts new file mode 100644 index 000000000..ae97ca004 --- /dev/null +++ b/packages/ui/src/stores/opencode-session-repair.ts @@ -0,0 +1,97 @@ +import { createSignal } from "solid-js" + +import type { OpenCodeSessionRepairAnalysis, OpenCodeSessionRepairMode, OpenCodeSessionRepairResult } from "../../../server/src/api-types" +import { serverApi } from "../lib/api-client" +import { getLogger } from "../lib/logger" +import { tGlobal } from "../lib/i18n" +import { showConfirmDialog } from "./alerts" +import { showToastNotification } from "../lib/notifications" +import { fetchSessions } from "./sessions" +import { instances } from "./instances" + +type RepairDialogState = { + loading: boolean + applying: boolean + analysis: OpenCodeSessionRepairAnalysis | null + result: OpenCodeSessionRepairResult | null + error: string | null +} + +const log = getLogger("actions") + +const [open, setOpen] = createSignal(false) +const [state, setState] = createSignal({ + loading: false, + applying: false, + analysis: null, + result: null, + error: null, +}) + +async function refreshAllInstanceSessions(): Promise { + const refreshes: Promise[] = [] + for (const instanceId of instances().keys()) { + refreshes.push(fetchSessions(instanceId).catch((error) => log.error("Failed to refresh sessions after repair", { instanceId, error }))) + } + await Promise.all(refreshes) +} + +async function analyzeOpenCodeSessions(): Promise { + setState((current) => ({ ...current, loading: true, error: null, result: null })) + try { + const analysis = await serverApi.analyzeOpenCodeSessionRepair() + setState((current) => ({ ...current, analysis, loading: false })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setState((current) => ({ ...current, loading: false, error: message })) + } +} + +async function openOpenCodeSessionRepairDialog(): Promise { + setOpen(true) + setState({ loading: false, applying: false, analysis: null, result: null, error: null }) + await analyzeOpenCodeSessions() +} + +async function applyOpenCodeSessionRepair(mode: OpenCodeSessionRepairMode): Promise { + const confirmed = await showConfirmDialog(tGlobal("commands.repairOpenCodeSessions.confirm.message"), { + title: tGlobal("commands.repairOpenCodeSessions.confirm.title"), + detail: tGlobal(`commands.repairOpenCodeSessions.confirm.detail.${mode}`), + confirmLabel: tGlobal("commands.repairOpenCodeSessions.confirm.confirmLabel"), + cancelLabel: tGlobal("commands.repairOpenCodeSessions.confirm.cancelLabel"), + dismissible: false, + }) + if (!confirmed) return + + setState((current) => ({ ...current, applying: true, error: null })) + try { + const result = await serverApi.executeOpenCodeSessionRepair({ mode }) + setState((current) => ({ ...current, applying: false, result, analysis: result.analysis })) + await refreshAllInstanceSessions() + showToastNotification({ + message: tGlobal("commands.repairOpenCodeSessions.toast.success"), + variant: "success", + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setState((current) => ({ ...current, applying: false, error: message })) + showToastNotification({ + message: tGlobal("commands.repairOpenCodeSessions.toast.error"), + variant: "error", + }) + } +} + +function closeOpenCodeSessionRepairDialog(): void { + if (state().applying) return + setOpen(false) +} + +export { + open as openOpenCodeSessionRepairDialogState, + state as openCodeSessionRepairDialogState, + openOpenCodeSessionRepairDialog, + closeOpenCodeSessionRepairDialog, + analyzeOpenCodeSessions, + applyOpenCodeSessionRepair, +}