diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 52c9007ea19..1a0afbab102 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -30,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) => export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` +export const projectClearNotificationsSelector = (slug: string) => + `[data-action="project-clear-notifications"][data-project="${slug}"]` + export const projectWorkspacesToggleSelector = (slug: string) => `[data-action="project-workspaces-toggle"][data-project="${slug}"]` diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 84f6a66d3c2..bd91e3ba322 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -21,6 +21,8 @@ import { import { Dynamic } from "solid-js/web" import type { FileNode } from "@kilocode/sdk/v2" +const MAX_DEPTH = 128 + function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } @@ -260,12 +262,20 @@ export default function FileTree(props: { _marks?: Set _deeps?: Map _kinds?: ReadonlyMap + _chain?: readonly string[] }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true const tooltip = () => props.tooltip ?? true + const key = (p: string) => + file + .normalize(p) + .replace(/[\\/]+$/, "") + .replaceAll("\\", "/") + const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] + const filter = createMemo(() => { if (props._filter) return props._filter @@ -307,23 +317,45 @@ export default function FileTree(props: { const out = new Map() - const visit = (dir: string, lvl: number): number => { - const expanded = file.tree.state(dir)?.expanded ?? false - if (!expanded) return -1 + const root = props.path + if (!(file.tree.state(root)?.expanded ?? false)) return out - const nodes = file.tree.children(dir) - const max = nodes.reduce((max, node) => { - if (node.type !== "directory") return max - const open = file.tree.state(node.path)?.expanded ?? false - if (!open) return max - return Math.max(max, visit(node.path, lvl + 1)) - }, lvl) + const seen = new Set() + const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] - out.set(dir, max) - return max + const push = (dir: string, lvl: number) => { + const id = key(dir) + if (seen.has(id)) return + seen.add(id) + + const kids = file.tree + .children(dir) + .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) + .map((node) => node.path) + + stack.push({ dir, lvl, i: 0, kids, max: lvl }) + } + + push(root, level - 1) + + while (stack.length > 0) { + const top = stack[stack.length - 1]! + + if (top.i < top.kids.length) { + const next = top.kids[top.i]! + top.i++ + push(next, top.lvl + 1) + continue + } + + out.set(top.dir, top.max) + stack.pop() + + const parent = stack[stack.length - 1] + if (!parent) continue + parent.max = Math.max(parent.max, top.max) } - visit(props.path, level - 1) return out }) @@ -459,21 +491,27 @@ export default function FileTree(props: { }} style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`} /> - + ...} + > + + diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index aa54f1be9f5..daa353050d4 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -509,6 +509,7 @@ export const dict = { "sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.", "sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.viewAllSessions": "عرض جميع الجلسات", + "sidebar.project.clearNotifications": "مسح الإشعارات", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "سطح المكتب", "settings.section.server": "الخادم", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index e4322ae6ef0..cc13da02db8 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -515,6 +515,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessões recentes", "sidebar.project.viewAllSessions": "Ver todas as sessões", + "sidebar.project.clearNotifications": "Limpar notificações", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Servidor", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index a11b4595e5c..ea29d15397b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -576,6 +576,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.", "sidebar.project.recentSessions": "Nedavne sesije", "sidebar.project.viewAllSessions": "Prikaži sve sesije", + "sidebar.project.clearNotifications": "Očisti obavijesti", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 741482c4240..ec085445c97 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -572,6 +572,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.project.recentSessions": "Seneste sessioner", "sidebar.project.viewAllSessions": "Vis alle sessioner", + "sidebar.project.clearNotifications": "Ryd notifikationer", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 7d8307b0f00..69f8de13692 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -524,6 +524,7 @@ export const dict = { "Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.", "sidebar.project.recentSessions": "Letzte Sitzungen", "sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen", + "sidebar.project.clearNotifications": "Benachrichtigungen löschen", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 976fcf597c0..331b9c04c78 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -582,6 +582,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", + "sidebar.project.clearNotifications": "Clear notifications", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 6a69bc7d793..bd9cc20baa1 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -579,6 +579,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", + "sidebar.project.clearNotifications": "Borrar notificaciones", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f7bf84e1c59..c44440c81b5 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -523,6 +523,7 @@ export const dict = { "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", "sidebar.project.viewAllSessions": "Voir toutes les sessions", + "sidebar.project.clearNotifications": "Effacer les notifications", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Bureau", "settings.section.server": "Serveur", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ff7b4b5ca7f..06fd7e05387 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -513,6 +513,7 @@ export const dict = { "sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。", "sidebar.project.recentSessions": "最近のセッション", "sidebar.project.viewAllSessions": "すべてのセッションを表示", + "sidebar.project.clearNotifications": "通知をクリア", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "デスクトップ", "settings.section.server": "サーバー", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 62a11c63fa6..0bdfeb07059 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -514,6 +514,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.", "sidebar.project.recentSessions": "최근 세션", "sidebar.project.viewAllSessions": "모든 세션 보기", + "sidebar.project.clearNotifications": "알림 지우기", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "데스크톱", "settings.section.server": "서버", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 616aed1e49f..2d009d4854d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -579,6 +579,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.project.recentSessions": "Nylige sesjoner", "sidebar.project.viewAllSessions": "Vis alle sesjoner", + "sidebar.project.clearNotifications": "Fjern varsler", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 02538e626dd..d89d1b23475 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -514,6 +514,7 @@ export const dict = { "sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.", "sidebar.project.recentSessions": "Ostatnie sesje", "sidebar.project.viewAllSessions": "Zobacz wszystkie sesje", + "sidebar.project.clearNotifications": "Wyczyść powiadomienia", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Pulpit", "settings.section.server": "Serwer", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 8a200b7d950..b3730fd0c15 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -578,6 +578,7 @@ export const dict = { "Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.", "sidebar.project.recentSessions": "Недавние сессии", "sidebar.project.viewAllSessions": "Посмотреть все сессии", + "sidebar.project.clearNotifications": "Очистить уведомления", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "Приложение", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 8de06487ea0..bac1e75b412 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -571,6 +571,7 @@ export const dict = { "sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ", "sidebar.project.recentSessions": "เซสชันล่าสุด", "sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด", + "sidebar.project.clearNotifications": "ล้างการแจ้งเตือน", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 860e35cb45a..97d3e4ac0d4 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -569,6 +569,7 @@ export const dict = { "sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.project.recentSessions": "最近会话", "sidebar.project.viewAllSessions": "查看全部会话", + "sidebar.project.clearNotifications": "清除通知", "app.name.desktop": "Kilo Desktop", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index d8cb85408d2..60d8751c16b 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -567,6 +567,7 @@ export const dict = { "sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.project.recentSessions": "最近工作階段", "sidebar.project.viewAllSessions": "查看全部工作階段", + "sidebar.project.clearNotifications": "清除通知", "app.name.desktop": "Kilo Desktop", "settings.section.desktop": "桌面", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0986cbadd1c..c2456efe7db 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1692,6 +1692,13 @@ export default function Layout(props: ParentProps) { }) const projectId = createMemo(() => panelProps.project?.id ?? "") const workspaces = createMemo(() => workspaceIds(panelProps.project)) + const unseenCount = createMemo(() => + workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + const clearNotifications = () => + workspaces() + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) const workspacesEnabled = createMemo(() => { const project = panelProps.project if (!project) return false @@ -1769,6 +1776,16 @@ export default function Layout(props: ParentProps) { : language.t("sidebar.workspaces.enable")} + + + {language.t("sidebar.project.clearNotifications")} + + active: Accessor overlay: Accessor + dirs: Accessor onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void @@ -70,73 +72,94 @@ const ProjectTile = (props: { setMenu: (value: boolean) => void setOpen: (value: boolean) => void language: ReturnType -}): JSX.Element => ( - { - props.setMenu(value) - if (value) props.setOpen(false) - }} - > - { + const notification = useNotification() + const unseenCount = createMemo(() => + props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + + const clear = () => + props + .dirs() + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) + + return ( + { + props.setMenu(value) + if (value) props.setOpen(false) }} - onMouseEnter={(event: MouseEvent) => { - if (!props.overlay()) return - props.onProjectMouseEnter(props.project.worktree, event) - }} - onMouseLeave={() => { - if (!props.overlay()) return - props.onProjectMouseLeave(props.project.worktree) - }} - onFocus={() => { - if (!props.overlay()) return - props.onProjectFocus(props.project.worktree) - }} - onClick={() => props.navigateToProject(props.project.worktree)} - onBlur={() => props.setOpen(false)} > - - - - - props.showEditProjectDialog(props.project)}> - {props.language.t("common.edit")} - - props.toggleProjectWorkspaces(props.project)} - > - - {props.workspacesEnabled(props.project) - ? props.language.t("sidebar.workspaces.disable") - : props.language.t("sidebar.workspaces.enable")} - - - - props.closeProject(props.project.worktree)} - > - {props.language.t("common.close")} - - - - -) + { + if (!props.overlay()) return + props.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!props.overlay()) return + props.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!props.overlay()) return + props.onProjectFocus(props.project.worktree) + }} + onClick={() => props.navigateToProject(props.project.worktree)} + onBlur={() => props.setOpen(false)} + > + + + + + props.showEditProjectDialog(props.project)}> + {props.language.t("common.edit")} + + props.toggleProjectWorkspaces(props.project)} + > + + {props.workspacesEnabled(props.project) + ? props.language.t("sidebar.workspaces.disable") + : props.language.t("sidebar.workspaces.enable")} + + + + {props.language.t("sidebar.project.clearNotifications")} + + + props.closeProject(props.project.worktree)} + > + {props.language.t("common.close")} + + + + + ) +} const ProjectPreviewPanel = (props: { project: LocalProject @@ -254,6 +277,7 @@ export const SortableProject = (props: { ) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) + const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const [open, setOpen] = createSignal(false) const [menu, setMenu] = createSignal(false) @@ -304,6 +328,7 @@ export const SortableProject = (props: { selected={selected} active={active} overlay={overlay} + dirs={dirs} onProjectMouseEnter={props.ctx.onProjectMouseEnter} onProjectMouseLeave={props.ctx.onProjectMouseLeave} onProjectFocus={props.ctx.onProjectFocus} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index f1448c21b4a..0934c5737cf 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -805,7 +805,7 @@ fn opencode_db_path() -> Result { } }; - Ok(data_home.join("kilo").join("kilo.db")) + Ok(data_home.join("kilo").join("kilo.db")) // kilocode_change } // Creates a `once` listener for the specified event and returns a future that resolves diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts new file mode 100644 index 00000000000..0ade4d3c4bf --- /dev/null +++ b/packages/opencode/src/cli/cmd/db.ts @@ -0,0 +1,68 @@ +import type { Argv } from "yargs" +import { spawn } from "child_process" +import { Database } from "../../storage/db" +import { Database as BunDatabase } from "bun:sqlite" +import { UI } from "../ui" +import { cmd } from "./cmd" + +const QueryCommand = cmd({ + command: "$0 [query]", + describe: "open an interactive sqlite3 shell or run a query", + builder: (yargs: Argv) => { + return yargs + .positional("query", { + type: "string", + describe: "SQL query to execute", + }) + .option("format", { + type: "string", + choices: ["json", "tsv"], + default: "tsv", + describe: "Output format", + }) + }, + handler: async (args: { query?: string; format: string }) => { + const query = args.query as string | undefined + if (query) { + const db = new BunDatabase(Database.Path, { readonly: true }) + try { + const result = db.query(query).all() as Record[] + if (args.format === "json") { + console.log(JSON.stringify(result, null, 2)) + } else if (result.length > 0) { + const keys = Object.keys(result[0]) + console.log(keys.join("\t")) + for (const row of result) { + console.log(keys.map((k) => row[k]).join("\t")) + } + } + } catch (err) { + UI.error(err instanceof Error ? err.message : String(err)) + process.exit(1) + } + db.close() + return + } + const child = spawn("sqlite3", [Database.Path], { + stdio: "inherit", + }) + await new Promise((resolve) => child.on("close", resolve)) + }, +}) + +const PathCommand = cmd({ + command: "path", + describe: "print the database path", + handler: () => { + console.log(Database.Path) + }, +}) + +export const DbCommand = cmd({ + command: "db", + describe: "database tools", + builder: (yargs: Argv) => { + return yargs.command(QueryCommand).command(PathCommand).demandCommand() + }, + handler: () => {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a96f70c1d44..948125946fb 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -42,6 +42,7 @@ if (!process.env[ENV_FEATURE]) { import { Config } from "./config/config" import { Auth } from "./auth" // kilocode_change end +import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" @@ -177,6 +178,7 @@ const cli = yargs(hideBin(process.argv)) // .command(GithubCommand) // kilocode_change (Disabled until backend is ready) .command(PrCommand) .command(SessionCommand) + .command(DbCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index d26ff249d6b..0708ebfbe5e 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -25,6 +25,7 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export namespace Database { + export const Path = path.join(Global.Path.data, "kilo.db") type Schema = typeof schema export type Transaction = SQLiteTransaction<"sync", void, Schema> diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 89d561188c7..e0684ce3c19 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -152,6 +152,7 @@ export namespace JsonMigration { sqlite.exec("BEGIN TRANSACTION") // Migrate projects first (no FK deps) + // Derive all IDs from file paths, not JSON content const projectIds = new Set() const projectValues = [] as any[] for (let i = 0; i < projectFiles.length; i += batchSize) { @@ -161,13 +162,10 @@ export namespace JsonMigration { for (let j = 0; j < batch.length; j++) { const data = batch[j] if (!data) continue - if (!data?.id) { - errs.push(`project missing id: ${projectFiles[i + j]}`) - continue - } - projectIds.add(data.id) + const id = path.basename(projectFiles[i + j], ".json") + projectIds.add(id) projectValues.push({ - id: data.id, + id, worktree: data.worktree ?? "/", vcs: data.vcs, name: data.name ?? undefined, @@ -186,6 +184,9 @@ export namespace JsonMigration { log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) }) // Migrate sessions (depends on projects) + // Derive all IDs from directory/file paths, not JSON content, since earlier + // migrations may have moved sessions to new directories without updating the JSON + const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file))) const sessionIds = new Set() const sessionValues = [] as any[] for (let i = 0; i < sessionFiles.length; i += batchSize) { @@ -195,18 +196,16 @@ export namespace JsonMigration { for (let j = 0; j < batch.length; j++) { const data = batch[j] if (!data) continue - if (!data?.id || !data?.projectID) { - errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`) - continue - } - if (!projectIds.has(data.projectID)) { + const id = path.basename(sessionFiles[i + j], ".json") + const projectID = sessionProjects[i + j] + if (!projectIds.has(projectID)) { orphans.sessions++ continue } - sessionIds.add(data.id) + sessionIds.add(id) sessionValues.push({ - id: data.id, - project_id: data.projectID, + id, + project_id: projectID, parent_id: data.parentID ?? null, slug: data.slug ?? "", directory: data.directory ?? "", @@ -253,11 +252,7 @@ export namespace JsonMigration { const data = batch[j] if (!data) continue const file = allMessageFiles[i + j] - const id = data.id ?? path.basename(file, ".json") - if (!id) { - errs.push(`message missing id: ${file}`) - continue - } + const id = path.basename(file, ".json") const sessionID = allMessageSessions[i + j] messageSessions.set(id, sessionID) const rest = data @@ -287,12 +282,8 @@ export namespace JsonMigration { const data = batch[j] if (!data) continue const file = partFiles[i + j] - const id = data.id ?? path.basename(file, ".json") - const messageID = data.messageID ?? path.basename(path.dirname(file)) - if (!id || !messageID) { - errs.push(`part missing id/messageID/sessionID: ${file}`) - continue - } + const id = path.basename(file, ".json") + const messageID = path.basename(path.dirname(file)) const sessionID = messageSessions.get(messageID) if (!sessionID) { errs.push(`part missing message session: ${file}`) diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index ff05d6d0595..b70c9e1ebe0 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -128,6 +128,28 @@ describe("JSON to SQLite migration", () => { expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) }) + test("uses filename for project id when JSON has different value", async () => { + await Bun.write( + path.join(storageDir, "project", "proj_filename.json"), + JSON.stringify({ + id: "proj_different_in_json", // Stale! Should be ignored + worktree: "/test/path", + vcs: "git", + name: "Test Project", + sandboxes: [], + }), + ) + + const stats = await JsonMigration.run(sqlite) + + expect(stats?.projects).toBe(1) + + const db = drizzle({ client: sqlite }) + const projects = db.select().from(ProjectTable).all() + expect(projects.length).toBe(1) + expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id + }) + test("migrates project with commands", async () => { await writeProject(storageDir, { id: "proj_with_commands", @@ -285,6 +307,74 @@ describe("JSON to SQLite migration", () => { expect(parts[0].data).not.toHaveProperty("sessionID") }) + test("uses filename for message id when JSON has different value", async () => { + await writeProject(storageDir, { + id: "proj_test123abc", + worktree: "/", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }) + await writeSession(storageDir, "proj_test123abc", { ...fixtures.session }) + await Bun.write( + path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"), + JSON.stringify({ + id: "msg_different_in_json", // Stale! Should be ignored + sessionID: "ses_test456def", + role: "user", + agent: "default", + time: { created: 1700000000000 }, + }), + ) + + const stats = await JsonMigration.run(sqlite) + + expect(stats?.messages).toBe(1) + + const db = drizzle({ client: sqlite }) + const messages = db.select().from(MessageTable).all() + expect(messages.length).toBe(1) + expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id + expect(messages[0].session_id).toBe("ses_test456def") + }) + + test("uses paths for part id and messageID when JSON has different values", async () => { + await writeProject(storageDir, { + id: "proj_test123abc", + worktree: "/", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }) + await writeSession(storageDir, "proj_test123abc", { ...fixtures.session }) + await Bun.write( + path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"), + JSON.stringify({ + role: "user", + agent: "default", + time: { created: 1700000000000 }, + }), + ) + await Bun.write( + path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"), + JSON.stringify({ + id: "prt_different_in_json", // Stale! Should be ignored + messageID: "msg_different_in_json", // Stale! Should be ignored + sessionID: "ses_test456def", + type: "text", + text: "Hello", + }), + ) + + const stats = await JsonMigration.run(sqlite) + + expect(stats?.parts).toBe(1) + + const db = drizzle({ client: sqlite }) + const parts = db.select().from(PartTable).all() + expect(parts.length).toBe(1) + expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id + expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID + }) + test("skips orphaned sessions (no parent project)", async () => { await Bun.write( path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"), @@ -304,6 +394,72 @@ describe("JSON to SQLite migration", () => { expect(stats?.sessions).toBe(0) }) + test("uses directory path for projectID when JSON has stale value", async () => { + // Simulates the scenario where earlier migration moved sessions to new + // git-based project directories but didn't update the projectID field + const gitBasedProjectID = "abc123gitcommit" + await writeProject(storageDir, { + id: gitBasedProjectID, + worktree: "/test/path", + vcs: "git", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }) + + // Session is in the git-based directory but JSON still has old projectID + await writeSession(storageDir, gitBasedProjectID, { + id: "ses_migrated", + projectID: "old-project-name", // Stale! Should be ignored + slug: "migrated-session", + directory: "/test/path", + title: "Migrated Session", + version: "1.0.0", + time: { created: 1700000000000, updated: 1700000001000 }, + }) + + const stats = await JsonMigration.run(sqlite) + + expect(stats?.sessions).toBe(1) + + const db = drizzle({ client: sqlite }) + const sessions = db.select().from(SessionTable).all() + expect(sessions.length).toBe(1) + expect(sessions[0].id).toBe("ses_migrated") + expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON + }) + + test("uses filename for session id when JSON has different value", async () => { + await writeProject(storageDir, { + id: "proj_test123abc", + worktree: "/test/path", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }) + + await Bun.write( + path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"), + JSON.stringify({ + id: "ses_different_in_json", // Stale! Should be ignored + projectID: "proj_test123abc", + slug: "test-session", + directory: "/test/path", + title: "Test Session", + version: "1.0.0", + time: { created: 1700000000000, updated: 1700000001000 }, + }), + ) + + const stats = await JsonMigration.run(sqlite) + + expect(stats?.sessions).toBe(1) + + const db = drizzle({ client: sqlite }) + const sessions = db.select().from(SessionTable).all() + expect(sessions.length).toBe(1) + expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id + expect(sessions[0].project_id).toBe("proj_test123abc") + }) + test("is idempotent (running twice doesn't duplicate)", async () => { await writeProject(storageDir, { id: "proj_test123abc", @@ -666,8 +822,11 @@ describe("JSON to SQLite migration", () => { const stats = await JsonMigration.run(sqlite) - expect(stats.projects).toBe(1) - expect(stats.sessions).toBe(1) + // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename) + // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path), + // ses_orphan (now uses dir path, ignores stale projectID) + expect(stats.projects).toBe(2) + expect(stats.sessions).toBe(3) expect(stats.messages).toBe(1) expect(stats.parts).toBe(1) expect(stats.todos).toBe(1) @@ -676,8 +835,8 @@ describe("JSON to SQLite migration", () => { expect(stats.errors.length).toBeGreaterThanOrEqual(6) const db = drizzle({ client: sqlite }) - expect(db.select().from(ProjectTable).all().length).toBe(1) - expect(db.select().from(SessionTable).all().length).toBe(1) + expect(db.select().from(ProjectTable).all().length).toBe(2) + expect(db.select().from(SessionTable).all().length).toBe(3) expect(db.select().from(MessageTable).all().length).toBe(1) expect(db.select().from(PartTable).all().length).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1)